diff --git a/.changes/next-release/feature-2c04c975-cdbf-4361-94e6-4010e68af8d9.json b/.changes/next-release/feature-2c04c975-cdbf-4361-94e6-4010e68af8d9.json new file mode 100644 index 0000000000..299864b545 --- /dev/null +++ b/.changes/next-release/feature-2c04c975-cdbf-4361-94e6-4010e68af8d9.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "AmazonQ /dev and /doc: Add support for complex workspaces." +} \ No newline at end of file diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt index 3b50f10ca9..3a75a029a7 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/common/session/SessionStateTypes.kt @@ -4,7 +4,7 @@ package software.aws.toolkits.jetbrains.common.session import software.aws.toolkits.jetbrains.common.util.AmazonQCodeGenService -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext open class SessionStateConfig( open val conversationId: String, diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/controller/DocController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/controller/DocController.kt index 8d1da5c0bb..88abc4d226 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/controller/DocController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/controller/DocController.kt @@ -13,9 +13,6 @@ import com.intellij.diff.util.DiffUserDataKeys import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.runInEdt import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ProjectRootManager -import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.wm.ToolWindowManager import com.intellij.testFramework.LightVirtualFile @@ -31,9 +28,9 @@ import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.common.util.selectFolder import software.aws.toolkits.jetbrains.core.coroutines.EDT -import software.aws.toolkits.jetbrains.services.amazonq.RepoSizeError import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController +import software.aws.toolkits.jetbrains.services.amazonq.project.RepoSizeError import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory import software.aws.toolkits.jetbrains.services.amazonqDoc.DEFAULT_RETRY_LIMIT import software.aws.toolkits.jetbrains.services.amazonqDoc.DIAGRAM_SVG_EXT @@ -81,7 +78,6 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.Cancellat import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.QFeatureEvent import software.aws.toolkits.jetbrains.services.codewhisperer.telemetry.broadcastQEvent import software.aws.toolkits.resources.message -import java.nio.file.Paths import java.util.UUID enum class DocGenerationStep { @@ -308,7 +304,7 @@ class DocController( val session = getSessionInfo(tabId) val docGenerationTask = docGenerationTasks.getTask(tabId) - val currentSourceFolder = session.context.selectedSourceFolder + val currentSourceFolder = session.context.selectionRoot try { messenger.sendFolderConfirmationMessage( @@ -405,7 +401,7 @@ class DocController( inMemoryFile.isWritable = false FileEditorManager.getInstance(context.project).openFile(inMemoryFile, true) } else { - val existingFile = VfsUtil.findRelativeFile(message.filePath, session.context.selectedSourceFolder) + val existingFile = VfsUtil.findRelativeFile(message.filePath, session.context.addressableRoot) val leftDiffContent = if (existingFile == null) { EmptyContent() } else { @@ -945,19 +941,9 @@ class DocController( } } - private fun isFolderPathInProjectModules(project: Project, folderPath: String): Boolean { - val path = Paths.get(folderPath) - val virtualFile = LocalFileSystem.getInstance().findFileByIoFile(path.toFile()) ?: return false - - val projectFileIndex = ProjectRootManager.getInstance(project).fileIndex - - return projectFileIndex.isInProject(virtualFile) - } - private suspend fun modifyDefaultSourceFolder(tabId: String) { val session = getSessionInfo(tabId) - val currentSourceFolder = session.context.selectedSourceFolder - val projectRoot = session.context.projectRoot + val workspaceRoot = session.context.workspaceRoot val docGenerationTask = docGenerationTasks.getTask(tabId) withContext(EDT) { @@ -967,7 +953,7 @@ class DocController( message = message("amazonqDoc.prompt.choose_folder_to_continue") ) - val selectedFolder = selectFolder(context.project, currentSourceFolder) + val selectedFolder = selectFolder(context.project, workspaceRoot) // No folder was selected if (selectedFolder == null) { @@ -980,9 +966,7 @@ class DocController( return@withContext } - val isFolderPathInProject = isFolderPathInProjectModules(context.project, selectedFolder.path) - - if (!isFolderPathInProject) { + if (!selectedFolder.path.startsWith(workspaceRoot.path)) { logger.info { "Selected folder not in workspace: ${selectedFolder.path}" } messenger.sendAnswer( @@ -1004,7 +988,7 @@ class DocController( return@withContext } - if (selectedFolder.path == projectRoot.path) { + if (selectedFolder.path == workspaceRoot.path) { docGenerationTask.folderLevel = DocFolderLevel.ENTIRE_WORKSPACE } else { docGenerationTask.folderLevel = DocFolderLevel.SUB_FOLDER @@ -1012,7 +996,7 @@ class DocController( logger.info { "Selected correct folder inside workspace: ${selectedFolder.path}" } - session.context.selectedSourceFolder = selectedFolder + session.context.selectionRoot = selectedFolder promptForDocTarget(tabId) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSession.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSession.kt index 2ace4a44f5..f2d93be4be 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSession.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSession.kt @@ -107,14 +107,12 @@ class DocSession(val tabID: String, val project: Project) { * Triggered by the Insert code follow-up button to apply code changes. */ fun insertChanges(filePaths: List, deletedFiles: List) { - val selectedSourceFolder = context.selectedSourceFolder.toNioPath() + filePaths.forEach { resolveAndCreateOrUpdateFile(context.addressableRoot.toNioPath(), it.zipFilePath, it.fileContent) } - filePaths.forEach { resolveAndCreateOrUpdateFile(selectedSourceFolder, it.zipFilePath, it.fileContent) } - - deletedFiles.forEach { resolveAndDeleteFile(selectedSourceFolder, it.zipFilePath) } + deletedFiles.forEach { resolveAndDeleteFile(context.addressableRoot.toNioPath(), it.zipFilePath) } // Taken from https://intellij-support.jetbrains.com/hc/en-us/community/posts/206118439-Refresh-after-external-changes-to-project-structure-and-sources - VfsUtil.markDirtyAndRefresh(true, true, true, context.selectedSourceFolder) + VfsUtil.markDirtyAndRefresh(true, true, true, context.addressableRoot) } private fun getFromReportedChanges(filePath: NewFileZipInfo): String? { @@ -158,7 +156,7 @@ class DocSession(val tabID: String, val project: Project) { } } else { val sourceContent = reportedChange - ?: VfsUtil.findRelativeFile(filePath.zipFilePath, context.selectedSourceFolder)?.content() + ?: VfsUtil.findRelativeFile(filePath.zipFilePath, context.addressableRoot)?.content() .orEmpty() val diffMetrics = getDiffMetrics(sourceContent, content) totalAddedLines += diffMetrics.insertedLines @@ -185,7 +183,7 @@ class DocSession(val tabID: String, val project: Project) { totalAddedChars += content.length totalAddedLines += content.split('\n').size } else { - val existingFileContent = VfsUtil.findRelativeFile(filePath.zipFilePath, context.selectedSourceFolder)?.content() + val existingFileContent = VfsUtil.findRelativeFile(filePath.zipFilePath, context.addressableRoot)?.content() val diffMetrics = getDiffMetrics(existingFileContent.orEmpty(), content) totalAddedLines += diffMetrics.insertedLines totalAddedChars += diffMetrics.insertedCharacters diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSessionContext.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSessionContext.kt index 07030476bd..7eded19904 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSessionContext.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqDoc/session/DocSessionContext.kt @@ -4,30 +4,6 @@ package software.aws.toolkits.jetbrains.services.amazonqDoc.session import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFile -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext -import software.aws.toolkits.jetbrains.services.amazonqDoc.SUPPORTED_DIAGRAM_EXT_SET -import software.aws.toolkits.jetbrains.services.amazonqDoc.SUPPORTED_DIAGRAM_FILE_NAME_SET +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext -class DocSessionContext(project: Project, maxProjectSizeBytes: Long? = null) : FeatureDevSessionContext(project, maxProjectSizeBytes) { - - /** - * Ensure diagram files are not ignored - */ - override fun getAdditionalGitIgnoreBinaryFilesRules(): Set { - val ignoreRules = super.getAdditionalGitIgnoreBinaryFilesRules() - val diagramExtRulesInGitIgnoreFormatSet = SUPPORTED_DIAGRAM_EXT_SET.map { "*.$it" }.toSet() - return ignoreRules - diagramExtRulesInGitIgnoreFormatSet - } - - /** - * Ensure diagram files are not filtered - */ - override fun isFileExtensionAllowed(file: VirtualFile): Boolean { - if (super.isFileExtensionAllowed(file)) { - return true - } - - return file.extension != null && SUPPORTED_DIAGRAM_FILE_NAME_SET.contains(file.name) - } -} +class DocSessionContext(project: Project, maxProjectSizeBytes: Long? = null) : FeatureDevSessionContext(project, maxProjectSizeBytes) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevExceptions.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevExceptions.kt index 0dcddc991d..41fd94d67f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevExceptions.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevExceptions.kt @@ -3,7 +3,7 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev -import software.aws.toolkits.jetbrains.services.amazonq.RepoSizeError +import software.aws.toolkits.jetbrains.services.amazonq.project.RepoSizeError import software.aws.toolkits.resources.message /** diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt index fa027cbb72..5481b28b76 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevController.kt @@ -29,10 +29,10 @@ import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.common.util.selectFolder import software.aws.toolkits.jetbrains.core.coroutines.EDT -import software.aws.toolkits.jetbrains.services.amazonq.RepoSizeError import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.project.RepoSizeError import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.CodeIterationLimitException import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.DEFAULT_RETRY_LIMIT @@ -249,7 +249,7 @@ class FeatureDevController( when (sessionState) { is PrepareCodeGenerationState -> { runInEdt { - val existingFile = VfsUtil.findRelativeFile(message.filePath, session.context.selectedSourceFolder) + val existingFile = VfsUtil.findRelativeFile(message.filePath, session.context.addressableRoot) val leftDiffContent = if (existingFile == null) { EmptyContent() @@ -336,7 +336,7 @@ class FeatureDevController( var pollAttempt = 0 val pollDelayMs = 10L while (pollAttempt < 5) { - val file = VfsUtil.findRelativeFile(message.filePath, session.context.selectedSourceFolder) + val file = VfsUtil.findRelativeFile(message.filePath, session.context.addressableRoot) // Wait for the file to be created and/or updated to the new content: if (file != null && file.content() == filePaths.find { it.zipFilePath == fileToUpdate }?.fileContent) { // Open a diff, showing the changes have been applied and the file now has identical left/right state: @@ -729,7 +729,7 @@ class FeatureDevController( val codeWhispererSettings = CodeWhispererSettings.getInstance().getAutoBuildSetting() val hasDevFile = session.context.checkForDevFile() - val isPromptedForAutoBuildFeature = codeWhispererSettings.containsKey(session.context.getWorkspaceRoot()) + val isPromptedForAutoBuildFeature = codeWhispererSettings.containsKey(session.context.workspaceRoot.path) if (hasDevFile && !isPromptedForAutoBuildFeature) { promptAllowQCommandsConsent(messenger, tabId) @@ -812,8 +812,7 @@ class FeatureDevController( private suspend fun modifyDefaultSourceFolder(tabId: String) { val session = getSessionInfo(tabId) - val currentSourceFolder = session.context.selectedSourceFolder - val projectRoot = session.context.projectRoot + val workspaceRoot = session.context.workspaceRoot val modifyFolderFollowUp = FollowUp( pillText = message("amazonqFeatureDev.follow_up.modify_source_folder"), @@ -825,7 +824,7 @@ class FeatureDevController( var reason: ModifySourceFolderErrorReason? = null withContext(EDT) { - val selectedFolder = selectFolder(context.project, currentSourceFolder) + val selectedFolder = selectFolder(context.project, workspaceRoot) // No folder was selected if (selectedFolder == null) { logger.info { "Cancelled dialog and not selected any folder" } @@ -839,8 +838,7 @@ class FeatureDevController( return@withContext } - // The folder is not in the workspace - if (!selectedFolder.path.startsWith(projectRoot.path)) { + if (!selectedFolder.path.startsWith(workspaceRoot.path)) { logger.info { "Selected folder not in workspace: ${selectedFolder.path}" } messenger.sendAnswer( @@ -860,7 +858,7 @@ class FeatureDevController( logger.info { "Selected correct folder inside workspace: ${selectedFolder.path}" } - session.context.selectedSourceFolder = selectedFolder + session.context.selectionRoot = selectedFolder result = Result.Succeeded messenger.sendAnswer( diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt index 1872f8df14..1c66473386 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationState.kt @@ -96,7 +96,7 @@ class CodeGenerationState( var insertedCharacters = 0 codeGenerationResult.newFiles.forEach { file -> // FIXME: Ideally, the before content should be read from the uploaded context instead of from disk, to avoid drift - val before = config.repoContext.selectedSourceFolder + val before = config.repoContext.addressableRoot .toNioPath() .resolve(file.zipFilePath) .toFile() diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationState.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationState.kt index b9dff5445d..7ffdfe67eb 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationState.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationState.kt @@ -49,7 +49,7 @@ class PrepareCodeGenerationState( messenger.sendAnswerPart(tabId = this.tabID, message = message("amazonqFeatureDev.chat_message.uploading_code")) messenger.sendUpdatePlaceholder(tabId = this.tabID, newPlaceholder = message("amazonqFeatureDev.chat_message.uploading_code")) - val isAutoBuildFeatureEnabled = CodeWhispererSettings.getInstance().isAutoBuildFeatureEnabled(this.config.repoContext.getWorkspaceRoot()) + val isAutoBuildFeatureEnabled = CodeWhispererSettings.getInstance().isAutoBuildFeatureEnabled(this.config.repoContext.workspaceRoot.path) val repoZipResult = config.repoContext.getProjectZip(isAutoBuildFeatureEnabled = isAutoBuildFeatureEnabled) val zipFileChecksum = repoZipResult.checksum zipFileLength = repoZipResult.contentLength diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt index dd1bf4bd7c..c0ab7fc72a 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/Session.kt @@ -8,8 +8,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtil import software.aws.toolkits.jetbrains.common.util.resolveAndCreateOrUpdateFile import software.aws.toolkits.jetbrains.common.util.resolveAndDeleteFile -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.CODE_GENERATION_RETRY_LIMIT import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ConversationIdNotFoundException import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FEATURE_NAME @@ -130,14 +130,13 @@ class Session(val tabID: String, val project: Project) { ) { val newFilePaths = filePaths.filter { !it.rejected && !it.changeApplied } val newDeletedFiles = deletedFiles.filter { !it.rejected && !it.changeApplied } - val selectedSourceFolder = context.selectedSourceFolder.toNioPath() runCatching { var insertedLines = 0 var insertedCharacters = 0 filePaths.forEach { file -> // FIXME: Ideally, the before content should be read from the uploaded context instead of from disk, to avoid drift - val before = selectedSourceFolder + val before = context.addressableRoot.toNioPath() .resolve(file.zipFilePath) .toFile() .let { f -> @@ -174,7 +173,7 @@ class Session(val tabID: String, val project: Project) { ReferenceLogController.addReferenceLog(references, project) // Taken from https://intellij-support.jetbrains.com/hc/en-us/community/posts/206118439-Refresh-after-external-changes-to-project-structure-and-sources - VfsUtil.markDirtyAndRefresh(true, true, true, context.selectedSourceFolder) + VfsUtil.markDirtyAndRefresh(true, true, true, context.addressableRoot) } // Suppressing because insertNewFiles needs to be a suspend function in order to be tested @@ -182,10 +181,8 @@ class Session(val tabID: String, val project: Project) { suspend fun insertNewFiles( filePaths: List, ) { - val selectedSourceFolder = context.selectedSourceFolder.toNioPath() - filePaths.forEach { - resolveAndCreateOrUpdateFile(selectedSourceFolder, it.zipFilePath, it.fileContent) + resolveAndCreateOrUpdateFile(context.addressableRoot.toNioPath(), it.zipFilePath, it.fileContent) it.changeApplied = true } } @@ -195,10 +192,8 @@ class Session(val tabID: String, val project: Project) { suspend fun applyDeleteFiles( deletedFiles: List, ) { - val selectedSourceFolder = context.selectedSourceFolder.toNioPath() - deletedFiles.forEach { - resolveAndDeleteFile(selectedSourceFolder, it.zipFilePath) + resolveAndDeleteFile(context.addressableRoot.toNioPath(), it.zipFilePath) it.changeApplied = true } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt index a8fd7af3f8..c1f8f24c87 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionStateTypes.kt @@ -4,7 +4,7 @@ package software.aws.toolkits.jetbrains.services.amazonqFeatureDev.session import com.fasterxml.jackson.annotation.JsonValue -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.FeatureDevService import software.aws.toolkits.jetbrains.services.cwc.messages.RecommendationContentSpan diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/workspace/context/WorkspaceTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/workspace/context/WorkspaceTest.kt new file mode 100644 index 0000000000..c0d443f89c --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/workspace/context/WorkspaceTest.kt @@ -0,0 +1,109 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.workspace.context + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.refreshAndFindVirtualDirectory +import com.intellij.openapi.vfs.refreshAndFindVirtualFile +import com.intellij.testFramework.LightPlatformTestCase +import software.aws.toolkits.jetbrains.services.amazonq.project.findWorkspaceRoot +import software.aws.toolkits.jetbrains.services.amazonq.project.isContentInWorkspace +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteExisting +import kotlin.io.path.exists + +class WorkspaceTest : LightPlatformTestCase() { + private lateinit var tempDir: Path + + override fun setUp() { + super.setUp() + tempDir = Files.createTempDirectory("workspace-test") + } + + override fun tearDown() { + if (tempDir.exists()) { + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach { it.deleteExisting() } + } + super.tearDown() + } + + private fun createDir(relativePath: String): VirtualFile { + val normalizedPath = relativePath.removePrefix("/") + return if (normalizedPath.isEmpty()) { + tempDir.refreshAndFindVirtualDirectory() ?: error("Failed to create directory") + } else { + tempDir.resolve(normalizedPath).createDirectories().refreshAndFindVirtualDirectory() ?: error("Failed to create directory") + } + } + + private fun createFile(path: String): VirtualFile { + val filePath = tempDir.resolve(path.removePrefix("/")) + Files.createDirectories(filePath.parent) + return Files.createFile(filePath).refreshAndFindVirtualFile() ?: error("Failed to create file") + } + + fun `testFindWorkspaceRoot returns null when no projects`() { + assertNull(findWorkspaceRoot(emptySet())) + } + + fun `test findWorkspaceRoot returns project path for single project`() { + val projectPath = createDir("test/project") + + assertEquals(projectPath, findWorkspaceRoot(setOf(projectPath))) + } + + fun `test findWorkspaceRoot returns project path for project with inner modules`() { + val path1 = createDir("test/projects/project1") + val path2 = createDir("test/projects/project1/module") + + assertEquals(path1, findWorkspaceRoot(setOf(path1, path2))) + } + + fun `test findWorkspaceRoot returns common root of multiple projects`() { + val path1 = createDir("test/projects/project1") + val path2 = createDir("test/projects/project2") + + assertEquals(createDir("test/projects"), findWorkspaceRoot(setOf(path1, path2))) + } + + fun `test findWorkspaceRoot returns common root of a project and external modules`() { + val projectPath = createDir("test/project") + val modulePath = createDir("test/external/module") + + assertEquals(createDir("test"), findWorkspaceRoot(setOf(projectPath, modulePath))) + } + + fun `test isContentInWorkspace returns false when workspace has no directories`() { + assertFalse(isContentInWorkspace(createDir("any/path"), emptySet())) + } + + fun `test isContentInWorkspace returns true for path in project`() { + val projectPath = createDir("test/project") + val testPath = createFile("test/project/src/file.txt") + + assertTrue(isContentInWorkspace(testPath, setOf(projectPath))) + } + + fun `test isContentInWorkspace returns true for path in external module`() { + val projectPath = createDir("test/project") + val modulePath = createDir("test/external/module") + + val testPath = createFile("test/external/module/src/file.txt") + + assertTrue(isContentInWorkspace(testPath, setOf(projectPath, modulePath))) + } + + fun `test isContentInWorkspace returns false for path outside project and modules`() { + val projectPath = createDir("test/project") + val modulePath = createDir("test/external/module") + + val testPath = createFile("other/path/file.txt") + + assertFalse(isContentInWorkspace(testPath, setOf(projectPath, modulePath))) + } +} diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevSessionContextTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevSessionContextTest.kt index c23e256849..05ec05e354 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevSessionContextTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/FeatureDevSessionContextTest.kt @@ -1,17 +1,14 @@ // Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import com.intellij.openapi.vfs.VirtualFile import com.intellij.testFramework.RuleChain import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevTestBase import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.FeatureDevService import software.aws.toolkits.jetbrains.utils.rules.HeavyJavaCodeInsightTestFixtureRule @@ -38,39 +35,7 @@ class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTest featureDevSessionContext = FeatureDevSessionContext(featureDevService.project, 1024) } - @Test - fun testWithDirectory() { - val directory = mock() - whenever(directory.extension).thenReturn(null) - whenever(directory.isDirectory).thenReturn(true) - assertTrue(featureDevSessionContext.isFileExtensionAllowed(directory)) - } - - @Test - fun testWithValidFile() { - val ktFile = mock() - whenever(ktFile.extension).thenReturn("kt") - whenever(ktFile.path).thenReturn("code.kt") - assertTrue(featureDevSessionContext.isFileExtensionAllowed(ktFile)) - } - - @Test - fun testWithInvalidFile() { - val mediaFile = mock() - whenever(mediaFile.extension).thenReturn("mp4") - assertFalse(featureDevSessionContext.isFileExtensionAllowed(mediaFile)) - } - - @Test - fun testAllowedFilePath() { - val allowedPaths = listOf("build.gradle", "gradle.properties", ".mvn/wrapper/maven-wrapper.properties") - allowedPaths.forEach({ - val txtFile = mock() - whenever(txtFile.path).thenReturn(it) - whenever(txtFile.extension).thenReturn(it.split(".").last()) - assertTrue(featureDevSessionContext.isFileExtensionAllowed(txtFile)) - }) - } + // FIXME: Add deeper tests, replacing previous shallow tests - BLOCKING @Test fun testZipProjectWithoutAutoDev() { @@ -78,14 +43,22 @@ class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTest false, setOf( "src/MyClass.java", - "gradlew", - "gradlew.bat", - "README.md", + "icons/menu.svg", + "assets/header.jpg", + "archive.zip", + "output.bin", + "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties", + "images/logo.png", "builder/GetTestBuilder.java", - "settings.gradle", - "build.gradle", + "gradlew", + "README.md", ".gitignore", + "License.md", + "gradlew.bat", + "license.txt", + "build.gradle", + "settings.gradle" ) ) } @@ -98,6 +71,8 @@ class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTest "src/MyClass.java", "icons/menu.svg", "assets/header.jpg", + "archive.zip", + "output.bin", "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties", "images/logo.png", @@ -106,8 +81,6 @@ class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTest "README.md", ".gitignore", "License.md", - "output.bin", - "archive.zip", "gradlew.bat", "license.txt", "build.gradle", @@ -161,49 +134,4 @@ class FeatureDevSessionContextTest : FeatureDevTestBase(HeavyJavaCodeInsightTest assertEquals(zippedFiles, expectedFiles) } - - @Test - fun testConvertGitIgnorePatternToRegex() { - val sampleGitIgnorePatterns = listOf(".*", "build/", "*.txt", "*.png") - val sampleFileNames = listOf( - ".gitignore/", - ".env/", - "file.txt/", - ".git/config/", - "src/file.txt/", - "build/", - "build/output.jar/", - "builds/", - "mybuild/", - "build.json/", - "log.txt/", - "file.txt.json/", - "file.png/", - "src/file.png/" - ) - - val patterns = sampleGitIgnorePatterns.map { pattern -> featureDevSessionContext.convertGitIgnorePatternToRegex(pattern).toRegex() } - - val matchedFiles = sampleFileNames.filter { fileName -> - patterns.any { pattern -> - pattern.matches(fileName) - } - } - - val expectedFilesToMatch = - listOf( - ".gitignore/", - ".env/", - "file.txt/", - ".git/config/", - "src/file.txt/", - "build/", - "build/output.jar/", - "log.txt/", - "file.png/", - "src/file.png/" - ) - - assertEquals(expectedFilesToMatch, matchedFiles) - } } diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt index 82fb4021d8..35ae183594 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/controller/FeatureDevControllerTest.kt @@ -33,11 +33,11 @@ import org.mockito.kotlin.spy import org.mockito.kotlin.times import org.mockito.kotlin.whenever import software.aws.toolkits.jetbrains.common.util.selectFolder -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthController import software.aws.toolkits.jetbrains.services.amazonq.auth.AuthNeededStates import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.CodeIterationLimitException import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.ContentLengthException import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.EmptyPatchException @@ -908,7 +908,7 @@ class FeatureDevControllerTest : FeatureDevTestBase() { whenever(featureDevClient.sendFeatureDevTelemetryEvent(any())).thenReturn(exampleSendTelemetryEventResponse) whenever(chatSessionStorage.getSession(any(), any())).thenReturn(spySession) - val folder = LightVirtualFile("${spySession.context.projectRoot.name}/path/to/sub/folder") + val folder = LightVirtualFile("${spySession.context.workspaceRoot.path.removePrefix("/")}/path/to/sub/folder") mockkStatic("software.aws.toolkits.jetbrains.common.util.FileUtilsKt") every { selectFolder(any(), any()) } returns folder diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt index bc31ea9d77..45d613f6b2 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/CodeGenerationStateTest.kt @@ -21,8 +21,8 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevException import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevTestBase import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.sendAnswerPart diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationStateTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationStateTest.kt index c547303609..3df897e053 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationStateTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/PrepareCodeGenerationStateTest.kt @@ -21,9 +21,9 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext -import software.aws.toolkits.jetbrains.services.amazonq.ZipCreationResult import software.aws.toolkits.jetbrains.services.amazonq.messages.MessagePublisher +import software.aws.toolkits.jetbrains.services.amazonq.project.FeatureDevSessionContext +import software.aws.toolkits.jetbrains.services.amazonq.project.ZipCreationResult import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.FeatureDevTestBase import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.messages.sendAnswerPart import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.util.CancellationTokenSource @@ -88,6 +88,7 @@ class PrepareCodeGenerationStateTest : FeatureDevTestBase() { val repoZipResult = ZipCreationResult(mockFile, testChecksumSha, testContentLength) val action = SessionStateAction("test-task", userMessage) + whenever(repoContext.workspaceRoot).thenReturn(mock()) whenever(repoContext.getProjectZip(false)).thenReturn(repoZipResult) every { featureDevService.createUploadUrl(any(), any(), any(), any()) } returns exampleCreateUploadUrlResponse diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt index 104cd06461..2bf1eb7765 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonqFeatureDev/session/SessionTest.kt @@ -79,16 +79,17 @@ class SessionTest : FeatureDevTestBase() { val mockNewFile = listOf(NewFileZipInfo("test.ts", "testContent", rejected = false, changeApplied = false)) val mockDeletedFile = listOf(DeletedFileInfo("deletedTest.ts", rejected = false, changeApplied = false)) - session.context.selectedSourceFolder = mock() - whenever(session.context.selectedSourceFolder.toNioPath()).thenReturn(Path("")) + val addressableRootPath = Path("src") + session.context.addressableRoot = mock() + whenever(session.context.addressableRoot.toNioPath()).thenReturn(addressableRootPath) runBlocking { session.insertChanges(mockNewFile, mockDeletedFile, emptyList()) } - verify(exactly = 1) { resolveAndDeleteFile(any(), "deletedTest.ts") } - verify(exactly = 1) { resolveAndCreateOrUpdateFile(any(), "test.ts", "testContent") } + verify(exactly = 1) { resolveAndDeleteFile(addressableRootPath, "deletedTest.ts") } + verify(exactly = 1) { resolveAndCreateOrUpdateFile(addressableRootPath, "test.ts", "testContent") } verify(exactly = 1) { ReferenceLogController.addReferenceLog(emptyList(), any()) } - verify(exactly = 1) { VfsUtil.markDirtyAndRefresh(true, true, true, any()) } + verify(exactly = 1) { VfsUtil.markDirtyAndRefresh(true, true, true, session.context.addressableRoot) } } } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts index e9de0369f2..aa7e389652 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts @@ -182,6 +182,7 @@ export class QuickActionHandler { "1. Generate code based on your description and the code in your workspace", "2. Provide a list of suggestions for you to review and add to your workspace", "3. If needed, iterate based on your feedback", + "", "To learn more, visit the [user guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html).", ].join("\n") }, diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt new file mode 100644 index 0000000000..d61c62bf38 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/FeatureDevSessionContext.kt @@ -0,0 +1,224 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.project + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileVisitor +import com.intellij.openapi.vfs.isFile +import com.intellij.platform.ide.progress.withBackgroundProgress +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.apache.commons.codec.digest.DigestUtils +import org.apache.commons.io.FileUtils +import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext +import software.aws.toolkits.jetbrains.services.amazonq.QConstants.MAX_FILE_SIZE_BYTES +import software.aws.toolkits.jetbrains.utils.isDevFile +import software.aws.toolkits.resources.AwsCoreBundle +import software.aws.toolkits.telemetry.AmazonqTelemetry +import java.io.File +import java.io.FileInputStream +import java.net.URI +import java.nio.file.FileSystem +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.util.Base64 +import java.util.UUID +import kotlin.io.path.Path +import kotlin.io.path.createParentDirectories +import kotlin.io.path.getPosixFilePermissions + +interface RepoSizeError { + val message: String +} + +class RepoSizeLimitError(override val message: String) : RuntimeException(), RepoSizeError + +open class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Long? = null) { + // workspaceContentRoots is all module content root directories of the workspace, + private val workspaceContentRoots = findWorkspaceContentRoots(project) + + /** + * workspaceRootDirectory is the concrete directory detected as the project or workspace root: + * + * @see addressableRoot + */ + var workspaceRoot = findWorkspaceRoot(workspaceContentRoots) ?: error("Cannot detect base workspace root") + + private val changeListManager = ChangeListManager.getInstance(project) + + private var _selectionRoot = workspaceRoot + + // This function checks for existence of `devfile.yaml` in customer's repository, currently only `devfile.yaml` is supported for this feature. + fun checkForDevFile(): Boolean { + val devFile = File(addressableRoot.toString(), "devfile.yaml") + return devFile.exists() + } + + fun getProjectZip(isAutoBuildFeatureEnabled: Boolean?): ZipCreationResult { + val zippedProject = runBlocking { + withBackgroundProgress(project, AwsCoreBundle.message("amazonqFeatureDev.placeholder.generating_code")) { + zipFiles(isAutoBuildFeatureEnabled) + } + } + val checkSum256: String = Base64.getEncoder().encodeToString(DigestUtils.sha256(FileInputStream(zippedProject))) + return ZipCreationResult(zippedProject, checkSum256, zippedProject.length()) + } + + private fun shouldIncludeInZipFile(file: VirtualFile, isAutoBuildFeatureEnabled: Boolean): Boolean { + // Large files always ignored: + if (file.length > MAX_FILE_SIZE_BYTES) { + return false + } + + // Exclude files specified by gitignore or outside the workspace: + if (!isWorkspaceSourceContent(file, workspaceContentRoots, changeListManager, additionalGlobalIgnoreRules)) { + return false + } + + // Exclude files outside the selection root working on a subset of the workspace: + if (!VfsUtil.isAncestor(selectionRoot, file, false)) { + return false + } + + // When auto build is enabled, include all other files: + return if (isAutoBuildFeatureEnabled) { + true + } else { + // When auto build is disabled, explicitly exclude devfile: + // FIXME: There should be a stronger signal to the agent than presence of the devfile in the uploaded files to enable auto build + !isDevFile(file) + } + } + + private suspend fun zipFiles(isAutoBuildFeatureEnabled: Boolean?): File = withContext(getCoroutineBgContext()) { + val files = mutableListOf() + val ignoredExtensionMap = mutableMapOf().withDefault { 0L } + var totalSize: Long = 0 + + workspaceContentRoots.forEach { contentRoot -> + VfsUtil.visitChildrenRecursively( + contentRoot, + object : VirtualFileVisitor(NO_FOLLOW_SYMLINKS) { + override fun visitFile(file: VirtualFile): Boolean { + val isIncluded = shouldIncludeInZipFile(file, isAutoBuildFeatureEnabled == true) + if (!isIncluded) { + val extension = file.extension.orEmpty() + ignoredExtensionMap[extension] = (ignoredExtensionMap[extension] ?: 0) + 1 + return false + } + + if (file.isFile) { + totalSize += file.length + files.add(file) + + if (maxProjectSizeBytes != null && totalSize > maxProjectSizeBytes) { + throw RepoSizeLimitError(AwsCoreBundle.message("amazonqFeatureDev.content_length.error_text")) + } + } + return true + } + } + ) + } + + for ((key, value) in ignoredExtensionMap) { + AmazonqTelemetry.bundleExtensionIgnored( + count = value, + filenameExt = key + ) + } + + // Process files in parallel + val filesToIncludeFlow = channelFlow { + // chunk with some reasonable number because we don't actually need a new job for each file + files.chunked(50).forEach { chunk -> + launch { + for (file in chunk) { + send(file) + } + } + } + } + + val zipFilePath = createTemporaryZipFileAsync { zipfs -> + val posixFileAttributeSubstr = "posix" + val isPosix = FileSystems.getDefault().supportedFileAttributeViews().contains(posixFileAttributeSubstr) + filesToIncludeFlow.collect { file -> + + if (!file.isDirectory) { + val externalFilePath = Path(file.path) + val relativePath = VfsUtil.getRelativePath(file, addressableRoot) + val zipfsPath = zipfs.getPath("/$relativePath") + withContext(getCoroutineBgContext()) { + zipfsPath.createParentDirectories() + try { + Files.copy(externalFilePath, zipfsPath, StandardCopyOption.REPLACE_EXISTING) + if (isPosix) { + val zipPermissionAttributeName = "zip:permissions" + Files.setAttribute(zipfsPath, zipPermissionAttributeName, externalFilePath.getPosixFilePermissions()) + } + } catch (e: NoSuchFileException) { + // Noop: Skip if file was deleted + } + } + } + } + } + zipFilePath + }.toFile() + + private suspend fun createTemporaryZipFileAsync(block: suspend (FileSystem) -> Unit): Path = withContext(getCoroutineBgContext()) { + // Don't use Files.createTempFile since the file must not be created for ZipFS to work + val tempFilePath: Path = Paths.get(FileUtils.getTempDirectory().absolutePath, "${UUID.randomUUID()}.zip") + val uri = URI.create("jar:${tempFilePath.toUri()}") + val env = hashMapOf("create" to "true") + val zipfs = FileSystems.newFileSystem(uri, env) + zipfs.use { + block(zipfs) + } + tempFilePath + } + + /** + * selectionRoot is the directory selected in replacement of the workspaceRoot when the workspace is too big to bundle for uploading + * + * @see addressableRoot + */ + var selectionRoot: VirtualFile + set(directory) { + _selectionRoot = directory + } + get() = _selectionRoot + + /** + * The addressable root of the current working file tree. + * + * This property serves as the source of truth for relative paths when: + * 1. Creating relative paths within zip uploads + * 2. Resolving paths when downloading from zip + * 3. Displaying relative paths to users + * + * @see addressableRoot + * @see workspaceRoot + * + * Note: Prefer this over workspaceRoot for path operations to maintain consistent path resolution across upload/download/display operations + * (i.e. We could change from selectionRoot to workspaceRoot here, and these use cases would change behavior in alignment with each other.) + */ + + var addressableRoot: VirtualFile + get() = selectionRoot + set(directory) { + selectionRoot = directory + } +} + +data class ZipCreationResult(val payload: File, val checksum: String, val contentLength: Long) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt index 9190e9d131..c944ff1ac5 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/ProjectContextProvider.kt @@ -11,6 +11,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileVisitor @@ -19,14 +20,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.services.amazonq.CHAT_EXPLICIT_PROJECT_CONTEXT_TIMEOUT -import software.aws.toolkits.jetbrains.services.amazonq.FeatureDevSessionContext import software.aws.toolkits.jetbrains.services.amazonq.SUPPLEMENTAL_CONTEXT_TIMEOUT import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings @@ -253,16 +252,19 @@ class ProjectContextProvider(val project: Project, private val encoderServer: En fun collectFiles(): FileCollectionResult { val collectedFiles = mutableListOf() var currentTotalFileSize = 0L - val featureDevSessionContext = FeatureDevSessionContext(project) val allFiles = mutableListOf() - project.getBaseDirectories().forEach { + + val projectBaseDirectories = project.getBaseDirectories() + val changeListManager = ChangeListManager.getInstance(project) + + projectBaseDirectories.forEach { VfsUtilCore.visitChildrenRecursively( it, object : VirtualFileVisitor(NO_FOLLOW_SYMLINKS) { // TODO: refactor this along with /dev & codescan file traversing logic override fun visitFile(file: VirtualFile): Boolean { if ((file.isDirectory && isBuildOrBin(file.name)) || - runBlocking { featureDevSessionContext.ignoreFile(file.name) } || + !isWorkspaceSourceContent(file, projectBaseDirectories, changeListManager, additionalGlobalIgnoreRulesForStrictSources) || (file.isFile && file.length > 10 * 1024 * 1024) ) { return false diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/Workspace.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/Workspace.kt new file mode 100644 index 0000000000..0be91f3372 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/Workspace.kt @@ -0,0 +1,111 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.project + +import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.telemetry.ALLOWED_CODE_EXTENSIONS + +fun findWorkspaceContentRoots(project: Project): Set { + val contentRoots = mutableSetOf() + + project.getBaseDirectories().forEach { contentRoot -> + if (contentRoot.exists()) { + contentRoots.add(contentRoot) + } + } + + return contentRoots +} + +fun findWorkspaceRoot(contentRoots: Set): VirtualFile? = + findCommonAncestor(contentRoots.toList()) + +fun isContentInWorkspace(path: VirtualFile, contentRoots: Set): Boolean = + contentRoots.any { root -> VfsUtil.isAncestor(root, path, false) } + +private fun findCommonAncestor(paths: List): VirtualFile? { + if (paths.isEmpty()) return null + if (paths.size == 1) return paths.first() + + var commonAncestor: VirtualFile = paths.first() + + while (!paths.all { VfsUtil.isAncestor(commonAncestor, it, false) }) { + commonAncestor = commonAncestor.parent ?: return null + } + + return commonAncestor +} + +/** + * Provides extremely limited pattern conversion for global ignore rules. + */ +fun regexTestOf(pattern: String): (path: VirtualFile) -> Boolean = + pattern + .replace(".", "\\.") + .replace("*", ".*") + .let { Regex("^$it$", RegexOption.IGNORE_CASE) } + .let { fun (path: VirtualFile) = it.matches(path.name) } + +/** + * Provides a lax set of global ignore rules, suitable for agents which may build and test code, avoiding over-filtering. + * + * If an agent needs to ignore more files (e.g. to reduce LLM context size), this should be done within the agent, instead of here at the workspace layer. + */ +val additionalGlobalIgnoreRules = setOf( + ".aws-sam", + ".gem", + ".git", + ".gradle", + ".hg", + ".idea", + ".project", + ".rvm", + ".svn", + "node_modules", + "build", + "dist", +).map(::regexTestOf) + +/** + * Provides an extremely strict set of global ignore rules, suitable for purely text-based-sources use cases. + */ +val additionalGlobalIgnoreRulesForStrictSources = + additionalGlobalIgnoreRules + + listOf( + // FIXME: It is incredibly brittle that this source of truth is a "telemetry" component + // It would be worth considering sniffing text vs arbitrary binary file contents, and making decisions on that basis, rather than file extension. + fun (path: VirtualFile) = !ALLOWED_CODE_EXTENSIONS.contains(path.extension), + ) + +/** + * Returns true if workspace source content, false otherwise. + * + * Workspace source content is defined as any files/folders which are: 1) within the workspace, and 2) not excluded from version control. + * + * @param path The file or folder to check + * @param changeListManager The VCS change list manager which will be checked for ignored files + * @param additionalIgnoreTests Additional ignore rules to enforce + */ +fun isWorkspaceSourceContent( + path: VirtualFile, + contentRoots: Set, + changeListManager: ChangeListManager, + additionalIgnoreTests: Iterable<(path: VirtualFile) -> Boolean>, +): Boolean { + // Exclude paths which are outside the workspace projects: + if (!isContentInWorkspace(path, contentRoots)) { + return false + } + + if (additionalIgnoreTests.any { it(path) }) { + return false + } + + // Check whether path is excluded from source control (i.e. gitignore): + return !changeListManager.isIgnoredFile(path) +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/FeatureDevSessionContext.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/FeatureDevSessionContext.kt deleted file mode 100644 index 3da2b4b276..0000000000 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/FeatureDevSessionContext.kt +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package software.aws.toolkits.jetbrains.services.amazonq - -import com.intellij.openapi.project.Project -import com.intellij.openapi.project.guessProjectDir -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.vfs.VirtualFileVisitor -import com.intellij.openapi.vfs.isFile -import com.intellij.platform.ide.progress.withBackgroundProgress -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import org.apache.commons.codec.digest.DigestUtils -import org.apache.commons.io.FileUtils -import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext -import software.aws.toolkits.jetbrains.services.amazonq.QConstants.MAX_FILE_SIZE_BYTES -import software.aws.toolkits.jetbrains.services.telemetry.ALLOWED_CODE_EXTENSIONS -import software.aws.toolkits.jetbrains.utils.isDevFile -import software.aws.toolkits.resources.AwsCoreBundle -import software.aws.toolkits.telemetry.AmazonqTelemetry -import java.io.File -import java.io.FileInputStream -import java.net.URI -import java.nio.file.FileSystem -import java.nio.file.FileSystems -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardCopyOption -import java.util.Base64 -import java.util.UUID -import kotlin.io.path.Path -import kotlin.io.path.createParentDirectories -import kotlin.io.path.getPosixFilePermissions -import kotlin.io.path.relativeTo - -interface RepoSizeError { - val message: String -} -class RepoSizeLimitError(override val message: String) : RuntimeException(), RepoSizeError - -open class FeatureDevSessionContext(val project: Project, val maxProjectSizeBytes: Long? = null) { - // TODO: Need to correct this class location in the modules going further to support both amazonq and codescan. - - private val additionalGitIgnoreFolderRules = setOf( - ".aws-sam", - ".gem", - ".git", - ".gradle", - ".hg", - ".idea", - ".project", - ".rvm", - ".svn", - "node_modules", - "build", - "dist", - ) - - private val defaultAdditionalGitIgnoreBinaryFilesRules = setOf( - "*.zip", - "*.bin", - "*.png", - "*.jpg", - "*.svg", - "*.pyc", - "license.txt", - "License.txt", - "LICENSE.txt", - "license.md", - "License.md", - "LICENSE.md", - ) - - // well known source files that do not have extensions - private val wellKnownSourceFiles = setOf( - "Dockerfile", - "Dockerfile.build", - "gradlew", - "mvnw" - ) - - // projectRoot: is the directory where the project is located when selected to open a project. - val projectRoot = project.guessProjectDir() ?: error("Cannot guess base directory for project ${project.name}") - private val projectRootPath = Paths.get(projectRoot.path) ?: error("Can not find project root path") - - // selectedSourceFolder: is the directory selected in replacement of the root, this happens when the project is too big to bundle for uploading. - private var _selectedSourceFolder = projectRoot - private var ignorePatternsWithGitIgnore = emptyList() - private var ignorePatternsForBinaryFiles = buildIgnorePatternsForBinaryFiles() - - private val gitIgnoreFile = File(selectedSourceFolder.path, ".gitignore") - - init { - ignorePatternsWithGitIgnore = try { - buildList { - addAll( - additionalGitIgnoreFolderRules - .map { convertGitIgnorePatternToRegex(it) } - ) - addAll(parseGitIgnore()) - }.mapNotNull { pattern -> - runCatching { Regex(pattern) }.getOrNull() - } - } catch (e: Exception) { - emptyList() - } - } - - private fun buildIgnorePatternsForBinaryFiles(): List = - getAdditionalGitIgnoreBinaryFilesRules() - .map { convertGitIgnorePatternToRegex(it) } - .mapNotNull { pattern -> - runCatching { Regex(pattern) }.getOrNull() - } - - open fun getAdditionalGitIgnoreBinaryFilesRules(): Set = defaultAdditionalGitIgnoreBinaryFilesRules - - // This function checks for existence of `devfile.yaml` in customer's repository, currently only `devfile.yaml` is supported for this feature. - fun checkForDevFile(): Boolean { - val devFile = File(projectRoot.path, "/devfile.yaml") - return devFile.exists() - } - - fun getWorkspaceRoot(): String = projectRoot.path - - fun getProjectZip(isAutoBuildFeatureEnabled: Boolean?): ZipCreationResult { - val zippedProject = runBlocking { - withBackgroundProgress(project, AwsCoreBundle.message("amazonqFeatureDev.placeholder.generating_code")) { - zipFiles(selectedSourceFolder, isAutoBuildFeatureEnabled) - } - } - val checkSum256: String = Base64.getEncoder().encodeToString(DigestUtils.sha256(FileInputStream(zippedProject))) - return ZipCreationResult(zippedProject, checkSum256, zippedProject.length()) - } - - open fun isFileExtensionAllowed(file: VirtualFile): Boolean { - // if it is a directory, it is allowed - if (file.isDirectory) return true - val extension = file.extension ?: return false - return ALLOWED_CODE_EXTENSIONS.contains(extension) - } - - fun ignoreFile(file: VirtualFile, applyExtraBinaryFilesRules: Boolean = true): Boolean = ignoreFile(file.presentableUrl, applyExtraBinaryFilesRules) - - fun ignoreFile(path: String, applyExtraBinaryFilesRules: Boolean = true): Boolean { - val allIgnoreRules = if (applyExtraBinaryFilesRules) ignorePatternsWithGitIgnore + ignorePatternsForBinaryFiles else ignorePatternsWithGitIgnore - val matchedRules = allIgnoreRules.map { pattern -> - // avoid partial match (pattern.containsMatchIn) since it causes us matching files - // against folder patterns. (e.g. settings.gradle ignored by .gradle rule!) - // we convert the glob rules to regex, add a trailing /* to all rules and then match - // entries against them by adding a trailing /. - // TODO: Add unit tests for gitignore matching - val relative = if (path.startsWith(projectRootPath.toString())) Paths.get(path).relativeTo(projectRootPath) else path - pattern.matches("$relative/") - } - return matchedRules.any { it } - } - - private fun wellKnown(file: VirtualFile): Boolean = wellKnownSourceFiles.contains(file.name) - - private fun shouldIncludeInZipFile(file: VirtualFile, isAutoBuildFeatureEnabled: Boolean): Boolean { - // large files always ignored - if (file.length > MAX_FILE_SIZE_BYTES) { - return false - } - - // always respect gitignore rules and remove binary files if auto build is disabled - val isFileIgnoredByPattern = ignoreFile(file, !isAutoBuildFeatureEnabled) - if (isFileIgnoredByPattern) { - return false - } - - // all other files are included when auto build enabled - if (isAutoBuildFeatureEnabled) { - return true - } - - // when auto build is disabled, only include files with well known extensions and names except "devfile.yam" - if (!isDevFile(file) && (wellKnown(file) || isFileExtensionAllowed(file))) { - return true - } - - // Any other files should not be included - return false - } - - suspend fun zipFiles(projectRoot: VirtualFile, isAutoBuildFeatureEnabled: Boolean?): File = withContext(getCoroutineBgContext()) { - val files = mutableListOf() - val ignoredExtensionMap = mutableMapOf().withDefault { 0L } - var totalSize: Long = 0 - - VfsUtil.visitChildrenRecursively( - projectRoot, - object : VirtualFileVisitor() { - override fun visitFile(file: VirtualFile): Boolean { - val isIncluded = shouldIncludeInZipFile(file, isAutoBuildFeatureEnabled == true) - if (!isIncluded) { - val extension = file.extension.orEmpty() - ignoredExtensionMap[extension] = (ignoredExtensionMap[extension] ?: 0) + 1 - return false - } - - if (file.isFile) { - totalSize += file.length - files.add(file) - - if (maxProjectSizeBytes != null && totalSize > maxProjectSizeBytes) { - throw RepoSizeLimitError(AwsCoreBundle.message("amazonqFeatureDev.content_length.error_text")) - } - } - return true - } - } - ) - - for ((key, value) in ignoredExtensionMap) { - AmazonqTelemetry.bundleExtensionIgnored( - count = value, - filenameExt = key - ) - } - - // Process files in parallel - val filesToIncludeFlow = channelFlow { - // chunk with some reasonable number because we don't actually need a new job for each file - files.chunked(50).forEach { chunk -> - launch { - for (file in chunk) { - send(file) - } - } - } - } - - val zipFilePath = createTemporaryZipFileAsync { zipfs -> - val posixFileAttributeSubstr = "posix" - val isPosix = FileSystems.getDefault().supportedFileAttributeViews().contains(posixFileAttributeSubstr) - filesToIncludeFlow.collect { file -> - - if (!file.isDirectory) { - val externalFilePath = Path(file.path) - val relativePath = Path(file.path).relativeTo(projectRootPath) - val zipfsPath = zipfs.getPath("/$relativePath") - withContext(getCoroutineBgContext()) { - zipfsPath.createParentDirectories() - try { - Files.copy(externalFilePath, zipfsPath, StandardCopyOption.REPLACE_EXISTING) - if (isPosix) { - val zipPermissionAttributeName = "zip:permissions" - Files.setAttribute(zipfsPath, zipPermissionAttributeName, externalFilePath.getPosixFilePermissions()) - } - } catch (e: NoSuchFileException) { - // Noop: Skip if file was deleted - } - } - } - } - } - zipFilePath - }.toFile() - - private suspend fun createTemporaryZipFileAsync(block: suspend (FileSystem) -> Unit): Path = withContext(getCoroutineBgContext()) { - // Don't use Files.createTempFile since the file must not be created for ZipFS to work - val tempFilePath: Path = Paths.get(FileUtils.getTempDirectory().absolutePath, "${UUID.randomUUID()}.zip") - val uri = URI.create("jar:${tempFilePath.toUri()}") - val env = hashMapOf("create" to "true") - val zipfs = FileSystems.newFileSystem(uri, env) - zipfs.use { - block(zipfs) - } - tempFilePath - } - - private fun parseGitIgnore(): Set { - if (!gitIgnoreFile.exists()) { - return emptySet() - } - return gitIgnoreFile.readLines() - .filterNot { it.isBlank() || it.startsWith("#") } - .map { it.trim() } - .map { convertGitIgnorePatternToRegex(it) } - .toSet() - } - - // gitignore patterns are not regex, method update needed. - fun convertGitIgnorePatternToRegex(pattern: String): String { - // Special case for ".*" to match only dotfiles - if (pattern == ".*") { - return "^\\..*/.*" - } - - return pattern - .replace(".", "\\.") - .replace("*", ".*") - .let { if (it.endsWith("/")) "$it.*" else "$it/.*" } // Add a trailing /* to all patterns. (we add a trailing / to all files when matching) - } - var selectedSourceFolder: VirtualFile - set(newRoot) { - _selectedSourceFolder = newRoot - } - get() = _selectedSourceFolder -} - -data class ZipCreationResult(val payload: File, val checksum: String, val contentLength: Long)