diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt index 52d5f575cc5..25fee69adc6 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt @@ -10,9 +10,11 @@ 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.VFileCopyEvent 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.VFileMoveEvent import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent import org.eclipse.lsp4j.CreateFilesParams import org.eclipse.lsp4j.DeleteFilesParams @@ -59,10 +61,24 @@ class WorkspaceServiceHandler( private fun didCreateFiles(events: List) { AmazonQLspService.executeIfRunning(project) { languageServer -> val validFiles = events.mapNotNull { event -> - val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null - toUriString(file)?.let { uri -> - FileCreate().apply { - this.uri = uri + when (event) { + is VFileCopyEvent -> { + val newFile = event.newParent.findChild(event.newChildName)?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(newFile)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } + } + else -> { + val file = event.file?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(file)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } } } } @@ -80,8 +96,17 @@ class WorkspaceServiceHandler( private fun didDeleteFiles(events: List) { AmazonQLspService.executeIfRunning(project) { languageServer -> val validFiles = events.mapNotNull { event -> - val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null - toUriString(file)?.let { uri -> + when (event) { + is VFileDeleteEvent -> { + val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(file) + } + is VFileMoveEvent -> { + val oldFile = event.oldParent?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(oldFile) + } + else -> null + }?.let { uri -> FileDelete().apply { this.uri = uri } @@ -129,15 +154,51 @@ class WorkspaceServiceHandler( private fun didChangeWatchedFiles(events: List) { AmazonQLspService.executeIfRunning(project) { languageServer -> - val validChanges = events.mapNotNull { event -> - event.file?.let { toUriString(it) }?.let { uri -> - FileEvent().apply { - this.uri = uri - type = when (event) { - is VFileCreateEvent -> FileChangeType.Created - is VFileDeleteEvent -> FileChangeType.Deleted - else -> FileChangeType.Changed - } + val validChanges = events.flatMap { event -> + when (event) { + is VFileCopyEvent -> { + event.newParent.findChild(event.newChildName)?.let { newFile -> + toUriString(newFile)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = FileChangeType.Created + } + ) + } + }.orEmpty() + } + is VFileMoveEvent -> { + listOfNotNull( + toUriString(event.oldParent)?.let { oldUri -> + FileEvent().apply { + uri = oldUri + type = FileChangeType.Deleted + } + }, + toUriString(event.file)?.let { newUri -> + FileEvent().apply { + uri = newUri + type = FileChangeType.Created + } + } + ) + } + else -> { + event.file?.let { file -> + toUriString(file)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = when (event) { + is VFileCreateEvent -> FileChangeType.Created + is VFileDeleteEvent -> FileChangeType.Deleted + else -> FileChangeType.Changed + } + } + ) + } + }.orEmpty() } } } @@ -155,8 +216,8 @@ class WorkspaceServiceHandler( override fun after(events: List) { // since we are using synchronous FileListener pluginAwareExecuteOnPooledThread { - didCreateFiles(events.filterIsInstance()) - didDeleteFiles(events.filterIsInstance()) + didCreateFiles(events.filter { it is VFileCreateEvent || it is VFileMoveEvent || it is VFileCopyEvent }) + didDeleteFiles(events.filter { it is VFileMoveEvent || it is VFileDeleteEvent }) didRenameFiles(events.filterIsInstance()) didChangeWatchedFiles(events) } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt index 0100d4d5dfb..d12527ff790 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt @@ -9,9 +9,11 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileCopyEvent 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.VFileMoveEvent import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent import com.intellij.util.messages.MessageBus import com.intellij.util.messages.MessageBusConnection @@ -167,6 +169,32 @@ class WorkspaceServiceHandlerTest { verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } } + @Test + fun `test didCreateFiles with move event`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertEquals(normalizeFileUri(newUri.toString()), paramsSlot.captured.files[0].uri) + } + + @Test + fun `test didCreateFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertEquals(normalizeFileUri(newUri.toString()), paramsSlot.captured.files[0].uri) + } + @Test fun `test didDeleteFiles with Python file`() = runTest { val pyUri = URI("file:///test/path") @@ -237,6 +265,48 @@ class WorkspaceServiceHandlerTest { assertEquals(normalizeFileUri(dirUri.toString()), paramsSlot.captured.files[0].uri) } + @Test + fun `test didDeleteFiles handles both delete and move events in same batch`() = runTest { + val deleteUri = URI("file:///test/deleteFile") + val oldMoveUri = URI("file:///test/oldMoveFile") + val newMoveUri = URI("file:///test/newMoveFile") + + val deleteEvent = createMockVFileEvent(deleteUri, FileChangeType.Deleted, false, "py") + val moveEvent = createMockVFileMoveEvent(oldMoveUri, newMoveUri, "test.py") + + sut.after(listOf(deleteEvent, moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertEquals(2, deleteParamsSlot.captured.files.size) + assertEquals(normalizeFileUri(deleteUri.toString()), deleteParamsSlot.captured.files[0].uri) + assertEquals(normalizeFileUri(oldMoveUri.toString()), deleteParamsSlot.captured.files[1].uri) + } + + @Test + fun `test didDeleteFiles with move event of unsupported file type`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.txt") + + sut.after(listOf(moveEvent)) + + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + } + + @Test + fun `test didDeleteFiles with move event of directory`() = runTest { + val oldUri = URI("file:///test/oldDir") + val newUri = URI("file:///test/newDir") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "", true) + + sut.after(listOf(moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertEquals(normalizeFileUri(oldUri.toString()), deleteParamsSlot.captured.files[0].uri) + } + @Test fun `test didChangeWatchedFiles with valid events`() = runTest { // Arrange @@ -262,6 +332,38 @@ class WorkspaceServiceHandlerTest { assertEquals(FileChangeType.Changed, paramsSlot.captured.changes[2].type) } + @Test + fun `test didChangeWatchedFiles with move event reports both delete and create`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + + assertEquals(2, paramsSlot.captured.changes.size) + assertEquals(normalizeFileUri(oldUri.toString()), paramsSlot.captured.changes[0].uri) + assertEquals(FileChangeType.Deleted, paramsSlot.captured.changes[0].type) + assertEquals(normalizeFileUri(newUri.toString()), paramsSlot.captured.changes[1].uri) + assertEquals(FileChangeType.Created, paramsSlot.captured.changes[1].type) + } + + @Test + fun `test didChangeWatchedFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + assertEquals(normalizeFileUri(newUri.toString()), paramsSlot.captured.changes[0].uri) + assertEquals(FileChangeType.Created, paramsSlot.captured.changes[0].type) + } + @Test fun `test no invoked messages when events are empty`() = runTest { // Act @@ -510,20 +612,28 @@ class WorkspaceServiceHandlerTest { assertEquals("folder2", paramsSlot.captured.event.removed[0].name) } - private fun createMockVFileEvent(uri: URI, type: FileChangeType = FileChangeType.Changed, isDirectory: Boolean, extension: String = "py"): VFileEvent { + private fun createMockVirtualFile(uri: URI, fileName: String, isDirectory: Boolean = false): VirtualFile { val nioPath = mockk { every { toUri() } returns uri } - val virtualFile = mockk { + return mockk { every { this@mockk.isDirectory } returns isDirectory every { toNioPath() } returns nioPath every { url } returns uri.path - every { path } returns "${uri.path}.$extension" + every { path } returns "${uri.path}/$fileName" every { fileSystem } returns mockk { every { protocol } returns "file" } } + } + private fun createMockVFileEvent( + uri: URI, + type: FileChangeType = FileChangeType.Changed, + isDirectory: Boolean = false, + extension: String = "py", + ): VFileEvent { + val virtualFile = createMockVirtualFile(uri, "test.$extension", isDirectory) return when (type) { FileChangeType.Deleted -> mockk() FileChangeType.Created -> mockk() @@ -533,46 +643,45 @@ class WorkspaceServiceHandlerTest { } } - // for didRename events private fun createMockPropertyChangeEvent( oldName: String, newName: String, isDirectory: Boolean = false, ): VFilePropertyChangeEvent { - val parentPath = mockk() - val filePath = mockk() + val oldUri = URI("file:///test/$oldName") + val newUri = URI("file:///test/$newName") + val file = createMockVirtualFile(newUri, newName, isDirectory) + every { file.parent } returns createMockVirtualFile(oldUri, oldName, isDirectory) - val parent = mockk { - every { toNioPath() } returns parentPath - every { this@mockk.isDirectory } returns isDirectory - every { path } returns "/test/$oldName" - every { url } returns "file:///test/$oldName" - every { fileSystem } returns mockk { - every { protocol } returns "file" - } + return mockk().apply { + every { propertyName } returns VirtualFile.PROP_NAME + every { this@apply.file } returns file + every { oldValue } returns oldName + every { newValue } returns newName } + } - val file = mockk { - every { toNioPath() } returns filePath - every { this@mockk.parent } returns parent - every { this@mockk.isDirectory } returns isDirectory - every { path } returns "/test/$newName" - every { url } returns "file:///test/$newName" + private fun createMockVFileMoveEvent(oldUri: URI, newUri: URI, fileName: String, isDirectory: Boolean = false): VFileMoveEvent { + val oldFile = createMockVirtualFile(oldUri, fileName, isDirectory) + val newFile = createMockVirtualFile(newUri, fileName, isDirectory) + return mockk().apply { + every { file } returns newFile + every { oldPath } returns oldUri.path + every { oldParent } returns oldFile + } + } + + private fun createMockVFileCopyEvent(originalUri: URI, newUri: URI, fileName: String): VFileCopyEvent { + val newParent = mockk { + every { findChild(any()) } returns createMockVirtualFile(newUri, fileName) every { fileSystem } returns mockk { every { protocol } returns "file" } } - - every { parentPath.resolve(oldName) } returns mockk { - every { toUri() } returns URI("file:///test/$oldName") - } - every { filePath.toUri() } returns URI("file:///test/$newName") - - return mockk().apply { - every { propertyName } returns VirtualFile.PROP_NAME - every { this@apply.file } returns file - every { oldValue } returns oldName - every { newValue } returns newName + return mockk().apply { + every { file } returns createMockVirtualFile(originalUri, fileName) + every { this@apply.newParent } returns newParent + every { newChildName } returns fileName } }