Skip to content

Commit cb97ce7

Browse files
feat(amazonq): implement workspace file messages (#5377)
Implement both workspace/didCreateFiles workspace/didDeleteFiles
1 parent 25f6e19 commit cb97ce7

File tree

5 files changed

+849
-14
lines changed

5 files changed

+849
-14
lines changed

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import org.eclipse.lsp4j.InitializedParams
3737
import org.eclipse.lsp4j.SynchronizationCapabilities
3838
import org.eclipse.lsp4j.TextDocumentClientCapabilities
3939
import org.eclipse.lsp4j.WorkspaceClientCapabilities
40-
import org.eclipse.lsp4j.WorkspaceFolder
4140
import org.eclipse.lsp4j.jsonrpc.Launcher
4241
import org.eclipse.lsp4j.launch.LSPLauncher
4342
import org.slf4j.event.Level
@@ -48,14 +47,15 @@ import software.aws.toolkits.jetbrains.isDeveloperMode
4847
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
4948
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
5049
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
50+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
51+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
5152
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
5253
import java.io.IOException
5354
import java.io.OutputStreamWriter
5455
import java.io.PipedInputStream
5556
import java.io.PipedOutputStream
5657
import java.io.PrintWriter
5758
import java.io.StringWriter
58-
import java.net.URI
5959
import java.nio.charset.StandardCharsets
6060
import java.util.concurrent.Future
6161
import kotlin.time.Duration.Companion.seconds
@@ -211,21 +211,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
211211
fileOperations = FileOperationsWorkspaceCapabilities().apply {
212212
didCreate = true
213213
didDelete = true
214+
didRename = true
214215
}
215216
}
216217
}
217218

218-
// needs case handling when project's base path is null: default projects/unit tests
219-
private fun createWorkspaceFolders(): List<WorkspaceFolder> =
220-
project.basePath?.let { basePath ->
221-
listOf(
222-
WorkspaceFolder(
223-
URI("file://$basePath").toString(),
224-
project.name
225-
)
226-
)
227-
}.orEmpty() // no folders to report or workspace not folder based
228-
229219
private fun createClientInfo(): ClientInfo {
230220
val metadata = ClientMetadata.getDefault()
231221
return ClientInfo().apply {
@@ -239,7 +229,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
239229
processId = ProcessHandle.current().pid().toInt()
240230
capabilities = createClientCapabilities()
241231
clientInfo = createClientInfo()
242-
workspaceFolders = createWorkspaceFolders()
232+
workspaceFolders = createWorkspaceFolders(project)
243233
initializationOptions = createExtendedClientMetadata()
244234
}
245235

@@ -306,6 +296,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
306296
}
307297

308298
DefaultAuthCredentialsService(project, encryptionManager, this)
299+
WorkspaceServiceHandler(project, this)
309300
}
310301

311302
override fun dispose() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
5+
6+
import com.intellij.openapi.project.Project
7+
import com.intellij.openapi.roots.ProjectRootManager
8+
import org.eclipse.lsp4j.WorkspaceFolder
9+
10+
object WorkspaceFolderUtil {
11+
fun createWorkspaceFolders(project: Project): List<WorkspaceFolder> =
12+
if (project.isDefault) {
13+
emptyList()
14+
} else {
15+
ProjectRootManager.getInstance(project).contentRoots.map { contentRoot ->
16+
WorkspaceFolder().apply {
17+
name = contentRoot.name
18+
this.uri = contentRoot.url
19+
}
20+
}
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace
5+
6+
import com.intellij.openapi.Disposable
7+
import com.intellij.openapi.project.Project
8+
import com.intellij.openapi.roots.ModuleRootEvent
9+
import com.intellij.openapi.roots.ModuleRootListener
10+
import com.intellij.openapi.vfs.VirtualFile
11+
import com.intellij.openapi.vfs.VirtualFileManager
12+
import com.intellij.openapi.vfs.newvfs.BulkFileListener
13+
import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
14+
import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent
15+
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
16+
import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent
17+
import org.eclipse.lsp4j.CreateFilesParams
18+
import org.eclipse.lsp4j.DeleteFilesParams
19+
import org.eclipse.lsp4j.DidChangeWatchedFilesParams
20+
import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams
21+
import org.eclipse.lsp4j.FileChangeType
22+
import org.eclipse.lsp4j.FileCreate
23+
import org.eclipse.lsp4j.FileDelete
24+
import org.eclipse.lsp4j.FileEvent
25+
import org.eclipse.lsp4j.FileRename
26+
import org.eclipse.lsp4j.RenameFilesParams
27+
import org.eclipse.lsp4j.WorkspaceFolder
28+
import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent
29+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
30+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
31+
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
32+
import java.nio.file.FileSystems
33+
import java.nio.file.Paths
34+
35+
class WorkspaceServiceHandler(
36+
private val project: Project,
37+
serverInstance: Disposable,
38+
) : BulkFileListener,
39+
ModuleRootListener {
40+
41+
private var lastSnapshot: List<WorkspaceFolder> = emptyList()
42+
private val supportedFilePatterns = FileSystems.getDefault().getPathMatcher(
43+
"glob:**/*.{ts,js,py,java}"
44+
)
45+
46+
init {
47+
project.messageBus.connect(serverInstance).subscribe(
48+
VirtualFileManager.VFS_CHANGES,
49+
this
50+
)
51+
52+
project.messageBus.connect(serverInstance).subscribe(
53+
ModuleRootListener.TOPIC,
54+
this
55+
)
56+
}
57+
58+
private fun didCreateFiles(events: List<VFileEvent>) {
59+
AmazonQLspService.executeIfRunning(project) { languageServer ->
60+
val validFiles = events.mapNotNull { event ->
61+
val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
62+
file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
63+
FileCreate().apply {
64+
this.uri = uri
65+
}
66+
}
67+
}
68+
69+
if (validFiles.isNotEmpty()) {
70+
languageServer.workspaceService.didCreateFiles(
71+
CreateFilesParams().apply {
72+
files = validFiles
73+
}
74+
)
75+
}
76+
}
77+
}
78+
79+
private fun didDeleteFiles(events: List<VFileEvent>) {
80+
AmazonQLspService.executeIfRunning(project) { languageServer ->
81+
val validFiles = events.mapNotNull { event ->
82+
val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
83+
file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
84+
FileDelete().apply {
85+
this.uri = uri
86+
}
87+
}
88+
}
89+
90+
if (validFiles.isNotEmpty()) {
91+
languageServer.workspaceService.didDeleteFiles(
92+
DeleteFilesParams().apply {
93+
files = validFiles
94+
}
95+
)
96+
}
97+
}
98+
}
99+
100+
private fun didRenameFiles(events: List<VFilePropertyChangeEvent>) {
101+
AmazonQLspService.executeIfRunning(project) { languageServer ->
102+
val validRenames = events
103+
.filter { it.propertyName == VirtualFile.PROP_NAME }
104+
.mapNotNull { event ->
105+
val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
106+
val oldName = event.oldValue as? String ?: return@mapNotNull null
107+
if (event.newValue !is String) return@mapNotNull null
108+
109+
// Construct old and new URIs
110+
val parentPath = file.parent?.toNioPath() ?: return@mapNotNull null
111+
val oldUri = parentPath.resolve(oldName).toUri().toString()
112+
val newUri = file.toNioPath().toUri().toString()
113+
114+
FileRename().apply {
115+
this.oldUri = oldUri
116+
this.newUri = newUri
117+
}
118+
}
119+
120+
if (validRenames.isNotEmpty()) {
121+
languageServer.workspaceService.didRenameFiles(
122+
RenameFilesParams().apply {
123+
files = validRenames
124+
}
125+
)
126+
}
127+
}
128+
}
129+
130+
private fun didChangeWatchedFiles(events: List<VFileEvent>) {
131+
AmazonQLspService.executeIfRunning(project) { languageServer ->
132+
val validChanges = events.mapNotNull { event ->
133+
event.file?.toNioPath()?.toUri()?.toString()?.takeIf { it.isNotEmpty() }?.let { uri ->
134+
FileEvent().apply {
135+
this.uri = uri
136+
type = when (event) {
137+
is VFileCreateEvent -> FileChangeType.Created
138+
is VFileDeleteEvent -> FileChangeType.Deleted
139+
else -> FileChangeType.Changed
140+
}
141+
}
142+
}
143+
}
144+
145+
if (validChanges.isNotEmpty()) {
146+
languageServer.workspaceService.didChangeWatchedFiles(
147+
DidChangeWatchedFilesParams().apply {
148+
changes = validChanges
149+
}
150+
)
151+
}
152+
}
153+
}
154+
155+
override fun after(events: List<VFileEvent>) {
156+
// since we are using synchronous FileListener
157+
pluginAwareExecuteOnPooledThread {
158+
didCreateFiles(events.filterIsInstance<VFileCreateEvent>())
159+
didDeleteFiles(events.filterIsInstance<VFileDeleteEvent>())
160+
didRenameFiles(events.filterIsInstance<VFilePropertyChangeEvent>())
161+
didChangeWatchedFiles(events)
162+
}
163+
}
164+
165+
override fun beforeRootsChange(event: ModuleRootEvent) {
166+
lastSnapshot = createWorkspaceFolders(project)
167+
}
168+
169+
override fun rootsChanged(event: ModuleRootEvent) {
170+
AmazonQLspService.executeIfRunning(project) { languageServer ->
171+
val currentSnapshot = createWorkspaceFolders(project)
172+
val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } }
173+
val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } }
174+
175+
if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) {
176+
languageServer.workspaceService.didChangeWorkspaceFolders(
177+
DidChangeWorkspaceFoldersParams().apply {
178+
this.event = WorkspaceFoldersChangeEvent().apply {
179+
added = addedFolders
180+
removed = removedFolders
181+
}
182+
}
183+
)
184+
}
185+
186+
lastSnapshot = currentSnapshot
187+
}
188+
}
189+
190+
private fun shouldHandleFile(file: VirtualFile): Boolean {
191+
if (file.isDirectory) {
192+
return true // Matches "**/*" with matches: "folder"
193+
}
194+
val path = Paths.get(file.path)
195+
val result = supportedFilePatterns.matches(path)
196+
return result
197+
}
198+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
5+
6+
import com.intellij.openapi.project.Project
7+
import com.intellij.openapi.roots.ProjectRootManager
8+
import com.intellij.openapi.vfs.VirtualFile
9+
import io.mockk.every
10+
import io.mockk.mockk
11+
import org.junit.jupiter.api.Assertions.assertEquals
12+
import org.junit.jupiter.api.Test
13+
14+
class WorkspaceFolderUtilTest {
15+
16+
@Test
17+
fun `createWorkspaceFolders returns empty list when no workspace folders`() {
18+
val mockProject = mockk<Project>()
19+
every { mockProject.isDefault } returns true
20+
21+
val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
22+
23+
assertEquals(emptyList<VirtualFile>(), result)
24+
}
25+
26+
@Test
27+
fun `createWorkspaceFolders returns workspace folders for non-default project`() {
28+
val mockProject = mockk<Project>()
29+
val mockProjectRootManager = mockk<ProjectRootManager>()
30+
val mockContentRoot1 = mockk<VirtualFile>()
31+
val mockContentRoot2 = mockk<VirtualFile>()
32+
33+
every { mockProject.isDefault } returns false
34+
every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
35+
every { mockProjectRootManager.contentRoots } returns arrayOf(mockContentRoot1, mockContentRoot2)
36+
37+
every { mockContentRoot1.name } returns "root1"
38+
every { mockContentRoot1.url } returns "file:///path/to/root1"
39+
every { mockContentRoot2.name } returns "root2"
40+
every { mockContentRoot2.url } returns "file:///path/to/root2"
41+
42+
val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
43+
44+
assertEquals(2, result.size)
45+
assertEquals("file:///path/to/root1", result[0].uri)
46+
assertEquals("file:///path/to/root2", result[1].uri)
47+
assertEquals("root1", result[0].name)
48+
assertEquals("root2", result[1].name)
49+
}
50+
51+
@Test
52+
fun `reateWorkspaceFolders returns empty list when project has no content roots`() {
53+
val mockProject = mockk<Project>()
54+
val mockProjectRootManager = mockk<ProjectRootManager>()
55+
56+
every { mockProject.isDefault } returns false
57+
every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
58+
every { mockProjectRootManager.contentRoots } returns emptyArray()
59+
60+
val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
61+
62+
assertEquals(emptyList<VirtualFile>(), result)
63+
}
64+
}

0 commit comments

Comments
 (0)