Skip to content

Commit f46518c

Browse files
authored
Merge branch 'feature/q-lsp' into rli/expose-capabilities
2 parents 93c0452 + cb97ce7 commit f46518c

File tree

17 files changed

+1396
-34
lines changed

17 files changed

+1396
-34
lines changed

buildspec/linuxUiTests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ phases:
4747
- chmod +x gradlew
4848

4949
- ffmpeg -loglevel quiet -nostdin -f x11grab -video_size ${SCREEN_WIDTH}x${SCREEN_HEIGHT} -i ${DISPLAY} -codec:v libx264 -pix_fmt yuv420p -vf drawtext="fontsize=48:box=1:[email protected]:boxborderw=5:fontcolor=white:x=0:y=h-text_h:text='%{gmtime\:%H\\\\\:%M\\\\\:%S}'" -framerate 12 -g 12 /tmp/screen_recording.mp4 &
50-
- ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME :ui-tests-starter:test coverageReport --console plain --info
50+
- ./gradlew -PideProfileName=$ALTERNATIVE_IDE_PROFILE_NAME :ui-tests-starter:uiTest coverageReport --console plain --info
5151

5252
post_build:
5353
commands:

noop/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
// project that does nothing
55
tasks.register("test")
6+
tasks.register("uiTest")

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
@@ -36,7 +36,6 @@ import org.eclipse.lsp4j.InitializedParams
3636
import org.eclipse.lsp4j.SynchronizationCapabilities
3737
import org.eclipse.lsp4j.TextDocumentClientCapabilities
3838
import org.eclipse.lsp4j.WorkspaceClientCapabilities
39-
import org.eclipse.lsp4j.WorkspaceFolder
4039
import org.eclipse.lsp4j.jsonrpc.Launcher
4140
import org.eclipse.lsp4j.launch.LSPLauncher
4241
import org.slf4j.event.Level
@@ -47,14 +46,15 @@ import software.aws.toolkits.jetbrains.isDeveloperMode
4746
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
4847
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
4948
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
49+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
50+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
5051
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
5152
import java.io.IOException
5253
import java.io.OutputStreamWriter
5354
import java.io.PipedInputStream
5455
import java.io.PipedOutputStream
5556
import java.io.PrintWriter
5657
import java.io.StringWriter
57-
import java.net.URI
5858
import java.nio.charset.StandardCharsets
5959
import java.util.concurrent.Future
6060
import kotlin.time.Duration.Companion.seconds
@@ -212,21 +212,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
212212
fileOperations = FileOperationsWorkspaceCapabilities().apply {
213213
didCreate = true
214214
didDelete = true
215+
didRename = true
215216
}
216217
}
217218
}
218219

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

@@ -310,6 +300,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
310300
}
311301

312302
DefaultAuthCredentialsService(project, encryptionManager, this)
303+
WorkspaceServiceHandler(project, this)
313304
}
314305

315306
override fun dispose() {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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.vfs.VfsUtilCore
7+
import com.intellij.openapi.vfs.VirtualFile
8+
import software.aws.toolkits.core.utils.getLogger
9+
import software.aws.toolkits.core.utils.warn
10+
import java.io.File
11+
import java.net.URI
12+
import java.net.URISyntaxException
13+
14+
object FileUriUtil {
15+
16+
fun toUriString(virtualFile: VirtualFile): String? {
17+
val protocol = virtualFile.fileSystem.protocol
18+
val uri = when (protocol) {
19+
"jar" -> VfsUtilCore.convertToURL(virtualFile.url)?.toExternalForm()
20+
"jrt" -> virtualFile.url
21+
else -> toUri(VfsUtilCore.virtualToIoFile(virtualFile)).toASCIIString()
22+
} ?: return null
23+
24+
return if (virtualFile.isDirectory) {
25+
uri.trimEnd('/', '\\')
26+
} else {
27+
uri
28+
}
29+
}
30+
31+
private fun toUri(file: File): URI {
32+
try {
33+
// URI scheme specified by language server protocol
34+
return URI("file", "", file.absoluteFile.toURI().path, null)
35+
} catch (e: URISyntaxException) {
36+
LOG.warn { "${e.localizedMessage}: $e" }
37+
return file.absoluteFile.toURI()
38+
}
39+
}
40+
41+
private val LOG = getLogger<FileUriUtil>()
42+
}
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+
}

0 commit comments

Comments
 (0)