Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(amazonq): implement workspace file messages #5377

Open
wants to merge 24 commits into
base: feature/q-lsp
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
686fda2
add workspace handler
samgst-amazon Feb 13, 2025
a5250b5
break up repeat code
samgst-amazon Feb 14, 2025
0efb633
use serverInstance as messageBus disposable
samgst-amazon Feb 14, 2025
788e833
init starts listeners
samgst-amazon Feb 14, 2025
429f5e9
update listeners
samgst-amazon Feb 17, 2025
aababb6
fix init params
samgst-amazon Feb 17, 2025
c3d855a
didChangeWatchedFiles impl
samgst-amazon Feb 18, 2025
f826372
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 18, 2025
fad4467
detekt
samgst-amazon Feb 18, 2025
ffa167a
move executeIfRunning
samgst-amazon Feb 18, 2025
94c25e4
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 18, 2025
87817ea
private class
samgst-amazon Feb 18, 2025
d495450
null uri handling
samgst-amazon Feb 19, 2025
7cff72e
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 19, 2025
70b7e53
didChangeWorkspaceFolders
samgst-amazon Feb 20, 2025
eaa993a
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 24, 2025
d139dc4
detekt
samgst-amazon Feb 24, 2025
5458f0c
add tests for changeWorkspaceFolders
samgst-amazon Feb 24, 2025
0628cae
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 26, 2025
3f2a98d
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 28, 2025
401826d
fix test
samgst-amazon Feb 28, 2025
780395e
glob pattern matching
samgst-amazon Feb 28, 2025
8ee75f8
Merge branch 'feature/q-lsp' into samgst/lsp-WSmessages
samgst-amazon Feb 28, 2025
de16193
add didRename
samgst-amazon Mar 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import org.eclipse.lsp4j.InitializedParams
import org.eclipse.lsp4j.SynchronizationCapabilities
import org.eclipse.lsp4j.TextDocumentClientCapabilities
import org.eclipse.lsp4j.WorkspaceClientCapabilities
import org.eclipse.lsp4j.WorkspaceFolder
import org.eclipse.lsp4j.jsonrpc.Launcher
import org.eclipse.lsp4j.launch.LSPLauncher
import org.slf4j.event.Level
Expand All @@ -48,14 +47,15 @@ import software.aws.toolkits.jetbrains.isDeveloperMode
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
import java.io.IOException
import java.io.OutputStreamWriter
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.io.PrintWriter
import java.io.StringWriter
import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.concurrent.Future
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -211,21 +211,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
fileOperations = FileOperationsWorkspaceCapabilities().apply {
didCreate = true
didDelete = true
didRename = true
}
}
}

// needs case handling when project's base path is null: default projects/unit tests
private fun createWorkspaceFolders(): List<WorkspaceFolder> =
project.basePath?.let { basePath ->
listOf(
WorkspaceFolder(
URI("file://$basePath").toString(),
project.name
)
)
}.orEmpty() // no folders to report or workspace not folder based

private fun createClientInfo(): ClientInfo {
val metadata = ClientMetadata.getDefault()
return ClientInfo().apply {
Expand All @@ -239,7 +229,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
processId = ProcessHandle.current().pid().toInt()
capabilities = createClientCapabilities()
clientInfo = createClientInfo()
workspaceFolders = createWorkspaceFolders()
workspaceFolders = createWorkspaceFolders(project)
initializationOptions = createExtendedClientMetadata()
}

Expand Down Expand Up @@ -306,6 +296,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
}

DefaultAuthCredentialsService(project, encryptionManager, this)
WorkspaceServiceHandler(project, this)
}

override fun dispose() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.util

import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectRootManager
import org.eclipse.lsp4j.WorkspaceFolder

object WorkspaceFolderUtil {
fun createWorkspaceFolders(project: Project): List<WorkspaceFolder> =
if (project.isDefault) {
emptyList()
} else {
ProjectRootManager.getInstance(project).contentRoots.map { contentRoot ->
WorkspaceFolder().apply {
name = contentRoot.name
this.uri = contentRoot.url
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace

import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ModuleRootEvent
import com.intellij.openapi.roots.ModuleRootListener
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent
import org.eclipse.lsp4j.CreateFilesParams
import org.eclipse.lsp4j.DeleteFilesParams
import org.eclipse.lsp4j.DidChangeWatchedFilesParams
import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams
import org.eclipse.lsp4j.FileChangeType
import org.eclipse.lsp4j.FileCreate
import org.eclipse.lsp4j.FileDelete
import org.eclipse.lsp4j.FileEvent
import org.eclipse.lsp4j.FileRename
import org.eclipse.lsp4j.RenameFilesParams
import org.eclipse.lsp4j.WorkspaceFolder
import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent
import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
import java.nio.file.FileSystems
import java.nio.file.Paths

class WorkspaceServiceHandler(
private val project: Project,
serverInstance: Disposable,
) : BulkFileListener,
ModuleRootListener {

private var lastSnapshot: List<WorkspaceFolder> = emptyList()
private val supportedFilePatterns = FileSystems.getDefault().getPathMatcher(
"glob:**/*.{ts,js,py,java}"
)
Comment on lines +42 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should read the patterns out from the server capabilities returned from the initialization message


init {
project.messageBus.connect(serverInstance).subscribe(
VirtualFileManager.VFS_CHANGES,
this
)

project.messageBus.connect(serverInstance).subscribe(
ModuleRootListener.TOPIC,
this
)
}

private fun didCreateFiles(events: List<VFileEvent>) {
AmazonQLspService.executeIfRunning(project) { languageServer ->
val validFiles = events.mapNotNull { event ->
val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
FileCreate().apply {
this.uri = uri
}
}
}

if (validFiles.isNotEmpty()) {
languageServer.workspaceService.didCreateFiles(
CreateFilesParams().apply {
files = validFiles
}
)
}
}
}

private fun didDeleteFiles(events: List<VFileEvent>) {
AmazonQLspService.executeIfRunning(project) { languageServer ->
val validFiles = events.mapNotNull { event ->
val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
FileDelete().apply {
this.uri = uri
}
}
}

if (validFiles.isNotEmpty()) {
languageServer.workspaceService.didDeleteFiles(
DeleteFilesParams().apply {
files = validFiles
}
)
}
}
}

private fun didRenameFiles(events: List<VFilePropertyChangeEvent>) {
AmazonQLspService.executeIfRunning(project) { languageServer ->
val validRenames = events
.filter { it.propertyName == VirtualFile.PROP_NAME }
.mapNotNull { event ->
val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
val oldName = event.oldValue as? String ?: return@mapNotNull null
val newName = event.newValue as? String ?: return@mapNotNull null

// Construct old and new URIs
val parentPath = file.parent?.toNioPath() ?: return@mapNotNull null
val oldUri = parentPath.resolve(oldName).toUri().toString()
val newUri = file.toNioPath().toUri().toString()

FileRename().apply {
this.oldUri = oldUri
this.newUri = newUri
}
}

if (validRenames.isNotEmpty()) {
languageServer.workspaceService.didRenameFiles(
RenameFilesParams().apply {
files = validRenames
}
)
}
}
}

private fun didChangeWatchedFiles(events: List<VFileEvent>) {
AmazonQLspService.executeIfRunning(project) { languageServer ->
val validChanges = events.mapNotNull { event ->
event.file?.toNioPath()?.toUri()?.toString()?.takeIf { it.isNotEmpty() }?.let { uri ->
FileEvent().apply {
this.uri = uri
type = when (event) {
is VFileCreateEvent -> FileChangeType.Created
is VFileDeleteEvent -> FileChangeType.Deleted
else -> FileChangeType.Changed
}
}
}
}

if (validChanges.isNotEmpty()) {
languageServer.workspaceService.didChangeWatchedFiles(
DidChangeWatchedFilesParams().apply {
changes = validChanges
}
)
}
}
}

override fun after(events: List<VFileEvent>) {
// since we are using synchronous FileListener
pluginAwareExecuteOnPooledThread {
didCreateFiles(events.filterIsInstance<VFileCreateEvent>())
didDeleteFiles(events.filterIsInstance<VFileDeleteEvent>())
didRenameFiles(events.filterIsInstance<VFilePropertyChangeEvent>())
didChangeWatchedFiles(events)
}
}

override fun beforeRootsChange(event: ModuleRootEvent) {
lastSnapshot = createWorkspaceFolders(project)
}

override fun rootsChanged(event: ModuleRootEvent) {
AmazonQLspService.executeIfRunning(project) { languageServer ->
val currentSnapshot = createWorkspaceFolders(project)
val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } }
val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } }

if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) {
languageServer.workspaceService.didChangeWorkspaceFolders(
DidChangeWorkspaceFoldersParams().apply {
this.event = WorkspaceFoldersChangeEvent().apply {
added = addedFolders
removed = removedFolders
}
}
)
}

lastSnapshot = currentSnapshot
}
}

private fun shouldHandleFile(file: VirtualFile): Boolean {
if (file.isDirectory) {
return true // Matches "**/*" with matches: "folder"
}
val path = Paths.get(file.path)
val result = supportedFilePatterns.matches(path)
return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.util

import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.openapi.vfs.VirtualFile
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class WorkspaceFolderUtilTest {

@Test
fun `createWorkspaceFolders returns empty list when no workspace folders`() {
val mockProject = mockk<Project>()
every { mockProject.isDefault } returns true

val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)

assertEquals(emptyList<VirtualFile>(), result)
}

@Test
fun `createWorkspaceFolders returns workspace folders for non-default project`() {
val mockProject = mockk<Project>()
val mockProjectRootManager = mockk<ProjectRootManager>()
val mockContentRoot1 = mockk<VirtualFile>()
val mockContentRoot2 = mockk<VirtualFile>()

every { mockProject.isDefault } returns false
every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
every { mockProjectRootManager.contentRoots } returns arrayOf(mockContentRoot1, mockContentRoot2)

every { mockContentRoot1.name } returns "root1"
every { mockContentRoot1.url } returns "file:///path/to/root1"
every { mockContentRoot2.name } returns "root2"
every { mockContentRoot2.url } returns "file:///path/to/root2"

val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)

assertEquals(2, result.size)
assertEquals("file:///path/to/root1", result[0].uri)
assertEquals("file:///path/to/root2", result[1].uri)
assertEquals("root1", result[0].name)
assertEquals("root2", result[1].name)
}

@Test
fun `reateWorkspaceFolders returns empty list when project has no content roots`() {
val mockProject = mockk<Project>()
val mockProjectRootManager = mockk<ProjectRootManager>()

every { mockProject.isDefault } returns false
every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
every { mockProjectRootManager.contentRoots } returns emptyArray()

val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)

assertEquals(emptyList<VirtualFile>(), result)
}
}
Loading
Loading