From 0b168f99d5ff3c6f67da92862b793d4571334a34 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Thu, 5 Jun 2025 11:09:06 -0700 Subject: [PATCH 01/32] Add language server to workspace --- aws-toolkit-vscode.code-workspace | 3 +++ .../decorations/aws-toolkit-vscode.code-workspace | 7 +++++++ 2 files changed, 10 insertions(+) create mode 100644 packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace diff --git a/aws-toolkit-vscode.code-workspace b/aws-toolkit-vscode.code-workspace index f03aafae2fe..479f9e8fd66 100644 --- a/aws-toolkit-vscode.code-workspace +++ b/aws-toolkit-vscode.code-workspace @@ -12,6 +12,9 @@ { "path": "packages/amazonq", }, + { + "path": "../language-servers", + }, ], "settings": { "typescript.tsdk": "node_modules/typescript/lib", diff --git a/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace b/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace new file mode 100644 index 00000000000..6087a16b3e9 --- /dev/null +++ b/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "../../../../..", + }, + ], +} From 562dc53ef5634a948fb5b596977ec0edcd2f331b Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Thu, 5 Jun 2025 11:13:40 -0700 Subject: [PATCH 02/32] Write diffController to open files on changing and render diff --- .../amazonq/src/lsp/chat/diffController.ts | 75 ++++++++++++ packages/amazonq/src/lsp/chat/messages.ts | 108 +++++++++++++++--- 2 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 packages/amazonq/src/lsp/chat/diffController.ts diff --git a/packages/amazonq/src/lsp/chat/diffController.ts b/packages/amazonq/src/lsp/chat/diffController.ts new file mode 100644 index 00000000000..56c16bda087 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffController.ts @@ -0,0 +1,75 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { diffLines } from 'diff' + +export class RealTimeDiffController { + private decorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 255, 0, 0.2)', + isWholeLine: true, + }) + + private deleteDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 0, 0, 0.2)', + isWholeLine: true, + }) + + async applyIncrementalDiff( + editor: vscode.TextEditor, + originalContent: string, + newContent: string, + isPartial: boolean = false + ) { + const diffs = diffLines(originalContent, newContent) + + const addDecorations: vscode.DecorationOptions[] = [] + const deleteDecorations: vscode.DecorationOptions[] = [] + + // Build incremental edits + await editor.edit((editBuilder) => { + let currentLine = 0 + + for (const part of diffs) { + const lines = part.value.split('\n').filter((l) => l !== '') + + if (part.removed) { + // For partial updates, don't delete yet, just mark + if (isPartial) { + const range = new vscode.Range(currentLine, 0, currentLine + lines.length, 0) + deleteDecorations.push({ range }) + } else { + // Final update, actually delete + const range = new vscode.Range(currentLine, 0, currentLine + lines.length, 0) + editBuilder.delete(range) + } + currentLine += lines.length + } else if (part.added) { + // Insert new content with decoration + const position = new vscode.Position(currentLine, 0) + editBuilder.insert(position, part.value) + + // Highlight the added lines + for (let idx = 0; idx < lines.length; idx++) { + addDecorations.push({ + range: new vscode.Range(currentLine + idx, 0, currentLine + idx + 1, 0), + }) + } + } else { + currentLine += lines.length + } + } + }) + + // Apply decorations after edit + editor.setDecorations(this.decorationType, addDecorations) + editor.setDecorations(this.deleteDecorationType, deleteDecorations) + } + + dispose() { + this.decorationType.dispose() + this.deleteDecorationType.dispose() + } +} diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index f0dcbd9e608..4b01338fb19 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -72,6 +72,33 @@ import { } from 'aws-core-vscode/amazonq' import { telemetry, TelemetryBase } from 'aws-core-vscode/telemetry' import { isValidResponseError } from './error' +import { RealTimeDiffController } from './diffController' + +// Add these at the module level +const editorCache = new Map() +const diffController = new RealTimeDiffController() + +// Helper function to open file for diff +async function openFileForDiff(filePath: string, tabId: string, languageClient: LanguageClient) { + try { + // Open the file in the editor + const document = await vscode.workspace.openTextDocument(filePath) + const editor = await vscode.window.showTextDocument(document, { + preview: false, + viewColumn: vscode.ViewColumn.Beside, // Open beside the chat + }) + + // Store the original content + const originalContent = document.getText() + + // Store the editor reference for real-time updates + editorCache.set(tabId, { editor, filePath, originalContent }) + + languageClient.info(`Opened file for diff: ${filePath}`) + } catch (error) { + languageClient.error(`Failed to open file for diff: ${error}`) + } +} export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) { languageClient.info( @@ -237,7 +264,13 @@ export function registerMessageListeners( lastPartialResult = partialResult as ChatResult } - void handlePartialResult(partialResult, encryptionKey, provider, chatParams.tabId) + void handlePartialResult( + partialResult, + encryptionKey, + provider, + chatParams.tabId, + languageClient + ) } ) @@ -297,7 +330,8 @@ export function registerMessageListeners( partialResult, encryptionKey, provider, - message.params.tabId + message.params.tabId, + languageClient ) ) @@ -435,21 +469,6 @@ export function registerMessageListeners( async (params: ShowDocumentParams): Promise> => { try { const uri = vscode.Uri.parse(params.uri) - - if (params.external) { - // Note: Not using openUrl() because we probably don't want telemetry for these URLs. - // Also it doesn't yet support the required HACK below. - - // HACK: workaround vscode bug: https://github.com/microsoft/vscode/issues/85930 - vscode.env.openExternal(params.uri as any).then(undefined, (e) => { - // TODO: getLogger('?').error('failed vscode.env.openExternal: %O', e) - vscode.env.openExternal(uri).then(undefined, (e) => { - // TODO: getLogger('?').error('failed vscode.env.openExternal: %O', e) - }) - }) - return params - } - const doc = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(doc, { preview: false }) return params @@ -497,7 +516,32 @@ export function registerMessageListeners( await ecc.viewDiff(viewDiffMessage, amazonQDiffScheme) }) - languageClient.onNotification(chatUpdateNotificationType.method, (params: ChatUpdateParams) => { + languageClient.onNotification(chatUpdateNotificationType.method, async (params: ChatUpdateParams) => { + // Check if this is a file write update + const messages = params.data?.messages + if (messages) { + for (const message of messages) { + // Handle fsWrite tool updates with file content + if (message.type === 'tool' && message.messageId && !message.messageId.startsWith('progress_')) { + const cachedEditor = editorCache.get(params.tabId) + if (cachedEditor && message.body) { + // Check if the message contains file content update + // This assumes the backend sends the content in the body + try { + await diffController.applyIncrementalDiff( + cachedEditor.editor, + cachedEditor.originalContent || '', + message.body, + true // isPartial + ) + } catch (error) { + languageClient.error(`Failed to apply diff: ${error}`) + } + } + } + } + } + void provider.webview?.postMessage({ command: chatUpdateNotificationType.method, params: params, @@ -510,6 +554,12 @@ export function registerMessageListeners( params: params, }) }) + + // Add cleanup when the webview is disposed + provider.webviewView?.onDidDispose(() => { + diffController.dispose() + editorCache.clear() + }) } function isServerEvent(command: string) { @@ -546,13 +596,33 @@ async function handlePartialResult( partialResult: string | T, encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, - tabId: string + tabId: string, + languageClient: LanguageClient ) { const decryptedMessage = typeof partialResult === 'string' && encryptionKey ? await decodeRequest(partialResult, encryptionKey) : (partialResult as T) + // Check if this is a fsWrite tool use starting + if (decryptedMessage.additionalMessages) { + for (const message of decryptedMessage.additionalMessages) { + if (message.type === 'tool' && message.messageId?.startsWith('progress_')) { + // Extract file path from the message + const fileList = message.header?.fileList + if (fileList?.filePaths?.[0] && fileList.details) { + const fileName = fileList.filePaths[0] + const filePath = fileList.details[fileName]?.description + + if (filePath) { + // Open the file immediately when fsWrite starts + await openFileForDiff(filePath, tabId, languageClient) + } + } + } + } + } + if (decryptedMessage.body !== undefined) { void provider.webview?.postMessage({ command: chatRequestType.method, From 10b64f1f8c63ff82e34542676568bd0bae7c48db Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Fri, 6 Jun 2025 17:13:12 -0700 Subject: [PATCH 03/32] Update message.ts to start animation while receiving ChatResult --- packages/amazonq/src/lsp/chat/messages.ts | 301 +++++++++++++++------- 1 file changed, 214 insertions(+), 87 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 4b01338fb19..0edc3b89a68 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -72,33 +72,10 @@ import { } from 'aws-core-vscode/amazonq' import { telemetry, TelemetryBase } from 'aws-core-vscode/telemetry' import { isValidResponseError } from './error' -import { RealTimeDiffController } from './diffController' +import { DiffAnimationHandler } from './diffAnimation/diffAnimationHandler' -// Add these at the module level -const editorCache = new Map() -const diffController = new RealTimeDiffController() - -// Helper function to open file for diff -async function openFileForDiff(filePath: string, tabId: string, languageClient: LanguageClient) { - try { - // Open the file in the editor - const document = await vscode.workspace.openTextDocument(filePath) - const editor = await vscode.window.showTextDocument(document, { - preview: false, - viewColumn: vscode.ViewColumn.Beside, // Open beside the chat - }) - - // Store the original content - const originalContent = document.getText() - - // Store the editor reference for real-time updates - editorCache.set(tabId, { editor, filePath, originalContent }) - - languageClient.info(`Opened file for diff: ${filePath}`) - } catch (error) { - languageClient.error(`Failed to open file for diff: ${error}`) - } -} +// Create a singleton instance of DiffAnimationHandler +let diffAnimationHandler: DiffAnimationHandler | undefined export function registerLanguageServerEventListener(languageClient: LanguageClient, provider: AmazonQChatViewProvider) { languageClient.info( @@ -151,9 +128,15 @@ export function registerMessageListeners( provider: AmazonQChatViewProvider, encryptionKey: Buffer ) { + // Initialize DiffAnimationHandler + if (!diffAnimationHandler) { + diffAnimationHandler = new DiffAnimationHandler() + languageClient.info('[message.ts] 🚀 Initialized DiffAnimationHandler') + } + const chatStreamTokens = new Map() // tab id -> token provider.webview?.onDidReceiveMessage(async (message) => { - languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) + languageClient.info(`[VSCode Client] 📨 Received ${JSON.stringify(message)} from chat`) if ((message.tabType && message.tabType !== 'cwc') || messageDispatcher.isLegacyEvent(message.command)) { // handle the mynah ui -> agent legacy flow @@ -255,22 +238,23 @@ export function registerMessageListeners( chatRequestType, partialResultToken, (partialResult) => { - // Store the latest partial result - if (typeof partialResult === 'string' && encryptionKey) { - void decodeRequest(partialResult, encryptionKey).then( - (decoded) => (lastPartialResult = decoded) - ) - } else { - lastPartialResult = partialResult as ChatResult - } - + // Process partial result with diff animation void handlePartialResult( partialResult, encryptionKey, provider, chatParams.tabId, - languageClient + languageClient, + diffAnimationHandler! ) + .then((decoded) => { + if (decoded) { + lastPartialResult = decoded + } + }) + .catch((error) => { + languageClient.error(`[message.ts] ❌ Error in partial result handler: ${error}`) + }) } ) @@ -283,7 +267,17 @@ export function registerMessageListeners( } const chatRequest = await encryptRequest(chatParams, encryptionKey) + + // Add timeout monitoring + const timeoutId = setTimeout(() => { + languageClient.warn( + `[message.ts] âš ī¸ Chat request taking longer than expected for tab ${chatParams.tabId}` + ) + }, 30000) // 30 seconds warning + try { + languageClient.info(`[message.ts] 📤 Sending chat request for tab ${chatParams.tabId}`) + const chatResult = await languageClient.sendRequest( chatRequestType.method, { @@ -292,31 +286,48 @@ export function registerMessageListeners( }, cancellationToken.token ) + + clearTimeout(timeoutId) + languageClient.info(`[message.ts] ✅ Received final chat result for tab ${chatParams.tabId}`) + await handleCompleteResult( chatResult, encryptionKey, provider, chatParams.tabId, - chatDisposable + chatDisposable, + languageClient, + diffAnimationHandler! ) } catch (e) { + clearTimeout(timeoutId) const errorMsg = `Error occurred during chat request: ${e}` - languageClient.info(errorMsg) - languageClient.info( - `Last result from langauge server: ${JSON.stringify(lastPartialResult, undefined, 2)}` - ) + languageClient.error(`[message.ts] ❌ ${errorMsg}`) + + // Log last partial result for debugging + if (lastPartialResult) { + languageClient.info( + `[message.ts] 📊 Last partial result before error: ${JSON.stringify(lastPartialResult, undefined, 2).substring(0, 500)}...` + ) + } + if (!isValidResponseError(e)) { throw e } + + // Try to handle the error result await handleCompleteResult( e.data, encryptionKey, provider, chatParams.tabId, - chatDisposable + chatDisposable, + languageClient, + diffAnimationHandler! ) } finally { chatStreamTokens.delete(chatParams.tabId) + clearTimeout(timeoutId) } break } @@ -326,12 +337,13 @@ export function registerMessageListeners( quickActionRequestType, quickActionPartialResultToken, (partialResult) => - handlePartialResult( + void handlePartialResult( partialResult, encryptionKey, provider, message.params.tabId, - languageClient + languageClient, + diffAnimationHandler! ) ) @@ -345,7 +357,9 @@ export function registerMessageListeners( encryptionKey, provider, message.params.tabId, - quickActionDisposable + quickActionDisposable, + languageClient, + diffAnimationHandler! ) break } @@ -489,6 +503,18 @@ export function registerMessageListeners( }) languageClient.onNotification(openFileDiffNotificationType.method, async (params: OpenFileDiffParams) => { + languageClient.info(`[message.ts] 🎨 Received openFileDiff notification: ${params.originalFileUri}`) + languageClient.info( + `[message.ts] 📏 Original content present: ${!!params.originalFileContent}, length: ${params.originalFileContent?.length || 0}` + ) + languageClient.info( + `[message.ts] 📏 New content present: ${!!params.fileContent}, length: ${params.fileContent?.length || 0}` + ) + + if (diffAnimationHandler) { + await diffAnimationHandler.processFileDiff(params) + } + const ecc = new EditorContentController() const uri = params.originalFileUri const doc = await vscode.workspace.openTextDocument(uri) @@ -517,31 +543,28 @@ export function registerMessageListeners( }) languageClient.onNotification(chatUpdateNotificationType.method, async (params: ChatUpdateParams) => { - // Check if this is a file write update - const messages = params.data?.messages - if (messages) { - for (const message of messages) { - // Handle fsWrite tool updates with file content - if (message.type === 'tool' && message.messageId && !message.messageId.startsWith('progress_')) { - const cachedEditor = editorCache.get(params.tabId) - if (cachedEditor && message.body) { - // Check if the message contains file content update - // This assumes the backend sends the content in the body - try { - await diffController.applyIncrementalDiff( - cachedEditor.editor, - cachedEditor.originalContent || '', - message.body, - true // isPartial - ) - } catch (error) { - languageClient.error(`Failed to apply diff: ${error}`) - } - } + languageClient.info(`[message.ts] 🔄 Received chatUpdate notification for tab: ${params.tabId}`) + languageClient.info(`[message.ts] 📊 Update contains ${params.data?.messages?.length || 0} messages`) + + // Log the messages in the update + if (params.data?.messages) { + for (const [index, msg] of params.data.messages.entries()) { + languageClient.info(`[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}`) + if (msg.header?.fileList) { + languageClient.info(`[message.ts] Has fileList: ${msg.header.fileList.filePaths?.join(', ')}`) } } } + // Process the update through DiffAnimationHandler + if (diffAnimationHandler && params.data?.messages) { + try { + await diffAnimationHandler.processChatUpdate(params) + } catch (error) { + languageClient.error(`[message.ts] ❌ Error processing chat update for animation: ${error}`) + } + } + void provider.webview?.postMessage({ command: chatUpdateNotificationType.method, params: params, @@ -555,10 +578,13 @@ export function registerMessageListeners( }) }) - // Add cleanup when the webview is disposed + // Cleanup when provider's webview is disposed provider.webviewView?.onDidDispose(() => { - diffController.dispose() - editorCache.clear() + if (diffAnimationHandler) { + diffAnimationHandler.dispose() + diffAnimationHandler = undefined + languageClient.info('[message.ts] đŸ’Ĩ Disposed DiffAnimationHandler') + } }) } @@ -597,32 +623,61 @@ async function handlePartialResult( encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, tabId: string, - languageClient: LanguageClient -) { + languageClient: LanguageClient, + diffAnimationHandler: DiffAnimationHandler +): Promise { + languageClient.info(`[message.ts] 📨 Processing partial result for tab ${tabId}`) + const decryptedMessage = typeof partialResult === 'string' && encryptionKey ? await decodeRequest(partialResult, encryptionKey) : (partialResult as T) - // Check if this is a fsWrite tool use starting - if (decryptedMessage.additionalMessages) { - for (const message of decryptedMessage.additionalMessages) { - if (message.type === 'tool' && message.messageId?.startsWith('progress_')) { - // Extract file path from the message - const fileList = message.header?.fileList - if (fileList?.filePaths?.[0] && fileList.details) { - const fileName = fileList.filePaths[0] - const filePath = fileList.details[fileName]?.description - - if (filePath) { - // Open the file immediately when fsWrite starts - await openFileForDiff(filePath, tabId, languageClient) - } + // Log the structure of the partial result + languageClient.info(`[message.ts] 📊 Partial result details:`) + languageClient.info(`[message.ts] - messageId: ${decryptedMessage.messageId}`) + languageClient.info(`[message.ts] - has body: ${!!decryptedMessage.body}`) + languageClient.info(`[message.ts] - has header: ${!!decryptedMessage.header}`) + languageClient.info(`[message.ts] - has fileList: ${!!decryptedMessage.header?.fileList}`) + languageClient.info( + `[message.ts] - additionalMessages count: ${decryptedMessage.additionalMessages?.length || 0}` + ) + + // Log additional messages in detail + if (decryptedMessage.additionalMessages && decryptedMessage.additionalMessages.length > 0) { + languageClient.info(`[message.ts] 📋 Additional messages in partial result:`) + for (const [index, msg] of decryptedMessage.additionalMessages.entries()) { + languageClient.info( + `[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}, body length: ${msg.body?.length || 0}` + ) + + // Check for diff content + if (msg.type === 'system-prompt' && msg.messageId) { + if (msg.messageId.endsWith('_original') || msg.messageId.endsWith('_new')) { + languageClient.info(`[message.ts] đŸŽ¯ Found diff content: ${msg.messageId}`) + } + } + + // Check for progress messages (file being processed) + if (msg.type === 'tool' && msg.messageId?.startsWith('progress_')) { + languageClient.info(`[message.ts] đŸŽŦ Found progress message: ${msg.messageId}`) + const fileList = msg.header?.fileList + if (fileList?.filePaths?.[0]) { + languageClient.info(`[message.ts] 📁 File being processed: ${fileList.filePaths[0]}`) + } + } + + // Check for final tool messages + if (msg.type === 'tool' && msg.messageId && !msg.messageId.startsWith('progress_')) { + languageClient.info(`[message.ts] 🔧 Found tool message: ${msg.messageId}`) + if (msg.header?.fileList) { + languageClient.info(`[message.ts] Files: ${msg.header.fileList.filePaths?.join(', ')}`) } } } } + // Send to UI first if (decryptedMessage.body !== undefined) { void provider.webview?.postMessage({ command: chatRequestType.method, @@ -631,6 +686,16 @@ async function handlePartialResult( tabId: tabId, }) } + + // Process for diff animation - IMPORTANT: pass true for isPartialResult + languageClient.info('[message.ts] đŸŽŦ Processing partial result for diff animation') + try { + await diffAnimationHandler.processChatResult(decryptedMessage, tabId, true) + } catch (error) { + languageClient.error(`[message.ts] ❌ Error processing partial result for animation: ${error}`) + } + + return decryptedMessage } /** @@ -642,16 +707,78 @@ async function handleCompleteResult( encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, tabId: string, - disposable: Disposable + disposable: Disposable, + languageClient: LanguageClient, + diffAnimationHandler: DiffAnimationHandler ) { + languageClient.info(`[message.ts] ✅ Processing complete result for tab ${tabId}`) + const decryptedMessage = typeof result === 'string' && encryptionKey ? await decodeRequest(result, encryptionKey) : (result as T) + + // Log complete result details + languageClient.info(`[message.ts] 📊 Complete result details:`) + languageClient.info(`[message.ts] - Main message ID: ${decryptedMessage.messageId}`) + languageClient.info( + `[message.ts] - Additional messages count: ${decryptedMessage.additionalMessages?.length || 0}` + ) + + // Log additional messages in detail + if (decryptedMessage.additionalMessages && decryptedMessage.additionalMessages.length > 0) { + languageClient.info(`[message.ts] 📋 Additional messages in complete result:`) + for (const [index, msg] of decryptedMessage.additionalMessages.entries()) { + languageClient.info( + `[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}, body length: ${msg.body?.length || 0}` + ) + + // Check for diff content + if (msg.type === 'system-prompt' && msg.messageId) { + if (msg.messageId.endsWith('_original')) { + const toolUseId = msg.messageId.replace('_original', '') + languageClient.info( + `[message.ts] đŸŽ¯ Found original diff content for toolUse: ${toolUseId}, content length: ${msg.body?.length || 0}` + ) + // Log first 100 chars of content + if (msg.body) { + languageClient.info(`[message.ts] Content preview: ${msg.body.substring(0, 100)}...`) + } + } else if (msg.messageId.endsWith('_new')) { + const toolUseId = msg.messageId.replace('_new', '') + languageClient.info( + `[message.ts] đŸŽ¯ Found new diff content for toolUse: ${toolUseId}, content length: ${msg.body?.length || 0}` + ) + // Log first 100 chars of content + if (msg.body) { + languageClient.info(`[message.ts] Content preview: ${msg.body.substring(0, 100)}...`) + } + } + } + + // Check for final tool messages + if (msg.type === 'tool' && msg.messageId && !msg.messageId.startsWith('progress_')) { + languageClient.info(`[message.ts] 🔧 Found tool completion message: ${msg.messageId}`) + if (msg.header?.fileList) { + languageClient.info(`[message.ts] Files affected: ${msg.header.fileList.filePaths?.join(', ')}`) + } + } + } + } + + // Send to UI void provider.webview?.postMessage({ command: chatRequestType.method, params: decryptedMessage, tabId: tabId, }) + // Process for diff animation - IMPORTANT: pass false for isPartialResult + languageClient.info('[message.ts] đŸŽŦ Processing complete result for diff animation') + try { + await diffAnimationHandler.processChatResult(decryptedMessage, tabId, false) + } catch (error) { + languageClient.error(`[message.ts] ❌ Error processing complete result for animation: ${error}`) + } + // only add the reference log once the request is complete, otherwise we will get duplicate log items for (const ref of decryptedMessage.codeReference ?? []) { ReferenceLogViewProvider.instance.addReferenceLog(referenceLogText(ref)) From ad520c1e303332dfff147b9c5d88b6ed6374d8b9 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Fri, 6 Jun 2025 17:13:37 -0700 Subject: [PATCH 04/32] Diff animation render --- .../diffAnimation/diffAnimationController.ts | 614 ++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts new file mode 100644 index 00000000000..c9f9b3143aa --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -0,0 +1,614 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from 'aws-core-vscode/shared' +import { diffLines, Change } from 'diff' + +export interface DiffAnimation { + uri: vscode.Uri + originalContent: string + newContent: string + decorations: { + additions: vscode.DecorationOptions[] + deletions: vscode.DecorationOptions[] + } +} + +export class DiffAnimationController { + // Make decorations more visible with stronger colors and animations + private readonly additionDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 255, 0, 0.3)', + isWholeLine: true, + border: '2px solid rgba(0, 255, 0, 0.8)', + borderRadius: '3px', + after: { + contentText: ' ✨ Added by Amazon Q', + color: 'rgba(0, 255, 0, 1)', + fontWeight: 'bold', + fontStyle: 'italic', + margin: '0 0 0 30px', + }, + // Add gutter icon for better visibility + gutterIconSize: 'contain', + overviewRulerColor: 'rgba(0, 255, 0, 0.8)', + overviewRulerLane: vscode.OverviewRulerLane.Right, + }) + + private readonly deletionDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 0, 0, 0.3)', + isWholeLine: true, + border: '2px solid rgba(255, 0, 0, 0.8)', + borderRadius: '3px', + textDecoration: 'line-through', + opacity: '0.6', + after: { + contentText: ' ❌ Removed by Amazon Q', + color: 'rgba(255, 0, 0, 1)', + fontWeight: 'bold', + fontStyle: 'italic', + margin: '0 0 0 30px', + }, + gutterIconSize: 'contain', + overviewRulerColor: 'rgba(255, 0, 0, 0.8)', + overviewRulerLane: vscode.OverviewRulerLane.Right, + }) + + // Highlight decoration for the current animating line + private readonly currentLineDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 255, 0, 0.2)', + isWholeLine: true, + border: '2px solid rgba(255, 255, 0, 1)', + borderRadius: '3px', + }) + + // Fade decoration for completed animations + private readonly fadeDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(0, 255, 0, 0.1)', + border: '1px solid rgba(0, 255, 0, 0.3)', + after: { + contentText: ' ✓', + color: 'rgba(0, 255, 0, 0.6)', + margin: '0 0 0 10px', + }, + }) + + private activeAnimations = new Map() + private animationTimeouts = new Map() + private animationSpeed = 50 // Faster for better real-time feel + private scrollDelay = 25 // Faster scrolling + private fadeDelay = 3000 // How long to keep fade decorations + private groupProximityLines = 5 // Lines within this distance are grouped + + constructor() { + getLogger().info('[DiffAnimationController] 🚀 Initialized') + } + + /** + * Start a diff animation for a file + */ + public async startDiffAnimation(filePath: string, originalContent: string, newContent: string): Promise { + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting diff animation for: ${filePath}`) + getLogger().info( + `[DiffAnimationController] 📊 Original: ${originalContent.length} chars, New: ${newContent.length} chars` + ) + + try { + // Stop any existing animation for this file + this.stopDiffAnimation(filePath) + + const uri = vscode.Uri.file(filePath) + + // Open or create the document + let document: vscode.TextDocument + let editor: vscode.TextEditor + + try { + // Try to find if document is already open + const openEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + + if (openEditor) { + editor = openEditor + document = openEditor.document + getLogger().info(`[DiffAnimationController] 📄 Found open editor for: ${filePath}`) + } else { + // Open the document + document = await vscode.workspace.openTextDocument(uri) + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Active, + }) + getLogger().info(`[DiffAnimationController] 📄 Opened document: ${filePath}`) + } + } catch (error) { + getLogger().info(`[DiffAnimationController] 🆕 File doesn't exist, creating new file`) + // Create the file with original content first + await vscode.workspace.fs.writeFile(uri, Buffer.from(originalContent)) + document = await vscode.workspace.openTextDocument(uri) + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Active, + }) + } + + // Calculate diff + const changes = diffLines(originalContent, newContent) + getLogger().info(`[DiffAnimationController] 📊 Calculated ${changes.length} change blocks`) + + // Store animation state + const decorations = this.calculateDecorations(changes, document, newContent) + const animation: DiffAnimation = { + uri, + originalContent, + newContent, + decorations, + } + this.activeAnimations.set(filePath, animation) + + // Apply the new content + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)) + edit.replace(uri, fullRange, newContent) + const applied = await vscode.workspace.applyEdit(edit) + + if (!applied) { + throw new Error('Failed to apply edit') + } + + // Wait for the document to update + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Re-get the document and editor after content change + document = await vscode.workspace.openTextDocument(uri) + const currentEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + + if (currentEditor) { + editor = currentEditor + } else { + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Active, + }) + } + + // Start the animation + await this.animateDiff(editor, decorations, filePath) + } catch (error) { + getLogger().error(`[DiffAnimationController] ❌ Failed to start diff animation: ${error}`) + throw error + } + } + + /** + * Support for incremental diff animation (for real-time updates) + */ + public async startIncrementalDiffAnimation( + filePath: string, + previousContent: string, + currentContent: string, + isFirstUpdate: boolean = false + ): Promise { + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting incremental animation for: ${filePath}`) + + // If it's the first update or empty previous content, use full animation + if (isFirstUpdate || previousContent === '') { + return this.startDiffAnimation(filePath, previousContent, currentContent) + } + + // For incremental updates, calculate diff from previous state + try { + const uri = vscode.Uri.file(filePath) + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + + if (!editor) { + // If editor not found, fall back to full animation + return this.startDiffAnimation(filePath, previousContent, currentContent) + } + + // Calculate incremental changes + const incrementalChanges = diffLines(previousContent, currentContent) + const hasChanges = incrementalChanges.some((change) => change.added || change.removed) + + if (!hasChanges) { + getLogger().info(`[DiffAnimationController] â„šī¸ No changes detected in incremental update`) + return + } + + // Apply content change + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(uri, fullRange, currentContent) + await vscode.workspace.applyEdit(edit) + + // Calculate decorations for new changes only + const decorations = this.calculateDecorations(incrementalChanges, editor.document, currentContent) + + // Animate only the new changes + await this.animateIncrementalDiff(editor, decorations, filePath) + } catch (error) { + getLogger().error( + `[DiffAnimationController] ❌ Incremental animation failed, falling back to full: ${error}` + ) + return this.startDiffAnimation(filePath, previousContent, currentContent) + } + } + + /** + * Calculate decorations based on diff changes for the NEW content + */ + private calculateDecorations( + changes: Change[], + document: vscode.TextDocument, + newContent: string + ): DiffAnimation['decorations'] { + const additions: vscode.DecorationOptions[] = [] + const deletions: vscode.DecorationOptions[] = [] + + // Split new content into lines for accurate mapping + let currentLineInNew = 0 + + getLogger().info(`[DiffAnimationController] 📊 Document has ${document.lineCount} lines`) + + for (let i = 0; i < changes.length; i++) { + const change = changes[i] + const changeLines = change.value.split('\n').filter((line) => line !== '') + const lineCount = changeLines.length + + getLogger().info( + `[DiffAnimationController] Change[${i}]: ${change.added ? 'ADD' : change.removed ? 'REMOVE' : 'KEEP'} ${lineCount} lines` + ) + + if (change.added) { + // For additions, highlight the lines in the new content + for (let j = 0; j < lineCount && currentLineInNew < document.lineCount; j++) { + try { + const line = document.lineAt(currentLineInNew) + additions.push({ + range: line.range, + hoverMessage: `Added: ${line.text}`, + }) + getLogger().info( + `[DiffAnimationController] ➕ Added line ${currentLineInNew}: "${line.text.substring(0, 50)}..."` + ) + currentLineInNew++ + } catch (error) { + getLogger().warn( + `[DiffAnimationController] âš ī¸ Could not highlight line ${currentLineInNew}: ${error}` + ) + currentLineInNew++ + } + } + } else if (change.removed) { + // For deletions, we track them but can't show in new content + for (let j = 0; j < lineCount; j++) { + getLogger().info( + `[DiffAnimationController] ➖ Removed line: "${changeLines[j]?.substring(0, 50) || ''}..."` + ) + } + } else { + // Unchanged lines + currentLineInNew += lineCount + } + } + + getLogger().info( + `[DiffAnimationController] 📊 Final decorations: ${additions.length} additions, ${deletions.length} deletions` + ) + return { additions, deletions } + } + + /** + * Animate diff changes progressively with smooth scrolling + */ + private async animateDiff( + editor: vscode.TextEditor, + decorations: DiffAnimation['decorations'], + filePath: string + ): Promise { + const { additions } = decorations + const timeouts: NodeJS.Timeout[] = [] + + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting animation with ${additions.length} additions`) + + // Clear previous decorations + editor.setDecorations(this.additionDecorationType, []) + editor.setDecorations(this.deletionDecorationType, []) + editor.setDecorations(this.currentLineDecorationType, []) + + // Group additions by proximity for smoother scrolling + const additionGroups = this.groupAdditionsByProximity(additions) + let currentGroupIndex = 0 + let additionsShown = 0 + + // If no additions, just show a completion message + if (additions.length === 0) { + getLogger().info(`[DiffAnimationController] â„šī¸ No additions to animate`) + return + } + + // Animate additions with progressive reveal and smart scrolling + for (let i = 0; i < additions.length; i++) { + const timeout = setTimeout(async () => { + if (!vscode.window.visibleTextEditors.includes(editor)) { + getLogger().warn(`[DiffAnimationController] âš ī¸ Editor closed, stopping animation`) + this.stopDiffAnimation(filePath) + return + } + + const currentAdditions = additions.slice(0, i + 1) + const currentAddition = additions[i] + + // Show all additions up to current + editor.setDecorations(this.additionDecorationType, currentAdditions) + + // Highlight current line being added + editor.setDecorations(this.currentLineDecorationType, [currentAddition]) + + // Clear current line highlight after a short delay + setTimeout(() => { + editor.setDecorations(this.currentLineDecorationType, []) + }, this.animationSpeed * 0.8) + + // Smart scrolling logic + const currentGroup = additionGroups[currentGroupIndex] + const isLastInGroup = currentGroup && i === currentGroup[currentGroup.length - 1].index + const shouldScroll = this.shouldScrollToLine(editor, currentAddition.range) + + if (shouldScroll || isLastInGroup) { + // Smooth scroll to the line + setTimeout(() => { + if (!vscode.window.visibleTextEditors.includes(editor)) { + return + } + + const revealType = this.getRevealType(editor, currentAddition.range, i === 0) + editor.revealRange(currentAddition.range, revealType) + + // Also set cursor position for better visibility + const newSelection = new vscode.Selection( + currentAddition.range.start, + currentAddition.range.start + ) + editor.selection = newSelection + }, this.scrollDelay) + + // Move to next group if we're at the end of current group + if (isLastInGroup && currentGroupIndex < additionGroups.length - 1) { + currentGroupIndex++ + } + } + + additionsShown++ + getLogger().info( + `[DiffAnimationController] đŸŽ¯ Animated ${additionsShown}/${additions.length} additions` + ) + }, i * this.animationSpeed) + + timeouts.push(timeout) + } + + // Add final timeout to fade decorations after animation + const fadeTimeout = setTimeout( + () => { + if (!vscode.window.visibleTextEditors.includes(editor)) { + getLogger().warn(`[DiffAnimationController] âš ī¸ Editor closed before fade`) + return + } + + // Gradually fade out decorations + editor.setDecorations(this.additionDecorationType, []) + editor.setDecorations(this.fadeDecorationType, additions) + + // Remove all decorations after fade + setTimeout(() => { + editor.setDecorations(this.fadeDecorationType, []) + this.activeAnimations.delete(filePath) + getLogger().info(`[DiffAnimationController] ✅ Animation fully completed for ${filePath}`) + }, this.fadeDelay) + + getLogger().info(`[DiffAnimationController] 🎨 Animation fading for ${filePath}`) + }, + additions.length * this.animationSpeed + 500 + ) + + timeouts.push(fadeTimeout) + this.animationTimeouts.set(filePath, timeouts) + } + + /** + * Animate incremental changes (optimized for real-time updates) + */ + private async animateIncrementalDiff( + editor: vscode.TextEditor, + decorations: DiffAnimation['decorations'], + filePath: string + ): Promise { + const { additions } = decorations + + if (additions.length === 0) { + getLogger().info(`[DiffAnimationController] â„šī¸ No incremental changes to animate`) + return + } + + // For incremental updates, show all changes immediately with a flash effect + editor.setDecorations(this.currentLineDecorationType, additions) + + // Flash effect + setTimeout(() => { + editor.setDecorations(this.currentLineDecorationType, []) + editor.setDecorations(this.additionDecorationType, additions) + }, 200) + + // Fade after a shorter delay for incremental updates + setTimeout(() => { + editor.setDecorations(this.additionDecorationType, []) + editor.setDecorations(this.fadeDecorationType, additions) + + setTimeout(() => { + editor.setDecorations(this.fadeDecorationType, []) + }, this.fadeDelay / 2) + }, 1000) + + // Scroll to first change + if (additions.length > 0 && this.shouldScrollToLine(editor, additions[0].range)) { + editor.revealRange(additions[0].range, vscode.TextEditorRevealType.InCenterIfOutsideViewport) + } + } + + /** + * Group additions by proximity for smarter scrolling + */ + private groupAdditionsByProximity( + additions: vscode.DecorationOptions[] + ): Array> { + const groups: Array> = [] + let currentGroup: Array<{ range: vscode.Range; index: number }> = [] + + for (let i = 0; i < additions.length; i++) { + const addition = additions[i] + + if (currentGroup.length === 0) { + currentGroup.push({ range: addition.range, index: i }) + } else { + const lastInGroup = currentGroup[currentGroup.length - 1] + const distance = addition.range.start.line - lastInGroup.range.end.line + + // Group additions that are within proximity + if (distance <= this.groupProximityLines) { + currentGroup.push({ range: addition.range, index: i }) + } else { + groups.push(currentGroup) + currentGroup = [{ range: addition.range, index: i }] + } + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup) + } + + getLogger().info( + `[DiffAnimationController] 📊 Grouped ${additions.length} additions into ${groups.length} groups` + ) + return groups + } + + /** + * Determine if we should scroll to a line + */ + private shouldScrollToLine(editor: vscode.TextEditor, range: vscode.Range): boolean { + const visibleRange = editor.visibleRanges[0] + if (!visibleRange) { + return true + } + + const line = range.start.line + const visibleStart = visibleRange.start.line + const visibleEnd = visibleRange.end.line + const buffer = 5 // Lines of buffer at top/bottom + + // Scroll if line is outside visible range with buffer + return line < visibleStart + buffer || line > visibleEnd - buffer + } + + /** + * Get appropriate reveal type based on context + */ + private getRevealType( + editor: vscode.TextEditor, + range: vscode.Range, + isFirst: boolean + ): vscode.TextEditorRevealType { + const visibleRange = editor.visibleRanges[0] + const targetLine = range.start.line + + if (isFirst) { + // First addition - center it + return vscode.TextEditorRevealType.InCenter + } else if (!visibleRange || targetLine < visibleRange.start.line || targetLine > visibleRange.end.line) { + // Line is outside visible range - center it + return vscode.TextEditorRevealType.InCenter + } else { + // Line is visible - use minimal scrolling + return vscode.TextEditorRevealType.InCenterIfOutsideViewport + } + } + + /** + * Stop diff animation for a file + */ + public stopDiffAnimation(filePath: string): void { + getLogger().info(`[DiffAnimationController] 🛑 Stopping animation for: ${filePath}`) + + const timeouts = this.animationTimeouts.get(filePath) + if (timeouts) { + for (const timeout of timeouts) { + clearTimeout(timeout) + } + this.animationTimeouts.delete(filePath) + } + + this.activeAnimations.delete(filePath) + + // Clear decorations if editor is still open + const editor = vscode.window.visibleTextEditors.find((e) => e.document.fileName === filePath) + if (editor) { + editor.setDecorations(this.additionDecorationType, []) + editor.setDecorations(this.deletionDecorationType, []) + editor.setDecorations(this.currentLineDecorationType, []) + editor.setDecorations(this.fadeDecorationType, []) + } + } + + /** + * Stop all active diff animations + */ + public stopAllAnimations(): void { + getLogger().info('[DiffAnimationController] 🛑 Stopping all animations') + for (const [filePath] of this.activeAnimations) { + this.stopDiffAnimation(filePath) + } + } + + /** + * Set animation speed (ms per line) + */ + public setAnimationSpeed(speed: number): void { + this.animationSpeed = Math.max(10, Math.min(500, speed)) + getLogger().info(`[DiffAnimationController] ⚡ Animation speed set to: ${this.animationSpeed}ms`) + } + + /** + * Check if an animation is currently active for a file + */ + public isAnimating(filePath: string): boolean { + return this.activeAnimations.has(filePath) + } + + /** + * Get animation statistics + */ + public getAnimationStats(): { activeCount: number; filePaths: string[] } { + return { + activeCount: this.activeAnimations.size, + filePaths: Array.from(this.activeAnimations.keys()), + } + } + + public dispose(): void { + getLogger().info('[DiffAnimationController] đŸ’Ĩ Disposing controller') + this.stopAllAnimations() + this.additionDecorationType.dispose() + this.deletionDecorationType.dispose() + this.currentLineDecorationType.dispose() + this.fadeDecorationType.dispose() + } +} From 5fa6623dcc0593927c9a05b43f9b5627334615d6 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Fri, 6 Jun 2025 17:14:06 -0700 Subject: [PATCH 05/32] Animation controller --- .../diffAnimation/diffAnimationHandler.ts | 897 ++++++++++++++++++ 1 file changed, 897 insertions(+) create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts new file mode 100644 index 00000000000..48ad51a8cbe --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -0,0 +1,897 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' +import { getLogger } from 'aws-core-vscode/shared' +import { DiffAnimationController } from './diffAnimationController' + +interface FileChangeInfo { + filePath: string + fileName: string + originalContent?: string + newContent?: string + messageId: string + changes?: { + added?: number + deleted?: number + } +} + +interface AnimationTask { + filePath: string + fileName: string + fileDetails: any + messageId: string + resolve: () => void + reject: (error: any) => void +} + +export class DiffAnimationHandler implements vscode.Disposable { + private diffAnimationController: DiffAnimationController + private fileChangeCache = new Map() + // Store diff content by toolUseId + private diffContentMap = new Map() + // Add a new cache to store the original content of files + private fileOriginalContentCache = new Map() + private disposables: vscode.Disposable[] = [] + + // Animation queue to prevent conflicts + private animationQueue: AnimationTask[] = [] + private isProcessingAnimation = false + // Track files currently being animated + private animatingFiles = new Set() + // Track processed message IDs to avoid duplicates + private processedMessages = new Set() + + // Track active files for real-time processing + private activeFiles = new Map< + string, + { + editor: vscode.TextEditor + originalContent: string + currentContent: string + toolUseId: string + } + >() + + constructor() { + getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler`) + this.diffAnimationController = new DiffAnimationController() + + // Listen to file open events and cache original content + const openTextDocumentDisposable = vscode.workspace.onDidOpenTextDocument((document) => { + const filePath = document.uri.fsPath + if (!this.fileOriginalContentCache.has(filePath) && document.uri.scheme === 'file') { + getLogger().info(`[DiffAnimationHandler] 📄 Caching original content for: ${filePath}`) + this.fileOriginalContentCache.set(filePath, document.getText()) + } + }) + this.disposables.push(openTextDocumentDisposable) + + // Listen to file change events + const changeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.scheme !== 'file') { + return + } + + const filePath = event.document.uri.fsPath + getLogger().info(`[DiffAnimationHandler] 📝 File change detected: ${filePath}`) + + // Skip if file is currently being animated + if (this.animatingFiles.has(filePath)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Skipping change event for animating file: ${filePath}`) + return + } + + const currentContent = event.document.getText() + + // Check if we have cached original content + const originalContent = this.fileOriginalContentCache.get(filePath) + if ( + originalContent !== undefined && + originalContent !== currentContent && + event.contentChanges.length > 0 + ) { + getLogger().info(`[DiffAnimationHandler] 🔄 Detected change in file: ${filePath}`) + // Update diff content mapping + this.diffContentMap.set(filePath, { + originalContent: originalContent, + newContent: currentContent, + filePath: filePath, + }) + } + }) + this.disposables.push(changeTextDocumentDisposable) + } + + /** + * Process streaming ChatResult updates - supports real-time animation + */ + public async processChatResult(chatResult: ChatResult, tabId: string, isPartialResult?: boolean): Promise { + getLogger().info( + `[DiffAnimationHandler] 📨 Processing ChatResult for tab ${tabId}, isPartial: ${isPartialResult}` + ) + getLogger().info( + `[DiffAnimationHandler] 📊 ChatResult details: messageId=${chatResult.messageId}, additionalMessagesCount=${chatResult.additionalMessages?.length || 0}` + ) + + try { + // Always process additional messages + if (chatResult.additionalMessages) { + getLogger().info( + `[DiffAnimationHandler] 📋 Processing ${chatResult.additionalMessages.length} additional messages` + ) + + for (const message of chatResult.additionalMessages) { + getLogger().info( + `[DiffAnimationHandler] 📌 Message: type=${message.type}, messageId=${message.messageId}` + ) + + // 1. Process diff content (system-prompt) + if (message.type === 'system-prompt' && message.messageId) { + await this.processDiffContent(message) + } + + // 2. Process progress messages (progress_) - open file immediately + if (message.type === 'tool' && message.messageId?.startsWith('progress_')) { + await this.processProgressMessage(message, tabId) + } + + // 3. Process tool completion messages - trigger animation + if ( + message.type === 'tool' && + message.messageId && + !message.messageId.startsWith('progress_') && + message.header?.fileList + ) { + await this.processToolCompleteMessage(message, tabId) + } + } + } + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to process chat result: ${error}`) + } + } + + /** + * Process ChatUpdateParams for file content updates + */ + public async processChatUpdate(params: ChatUpdateParams): Promise { + getLogger().info(`[DiffAnimationHandler] 🔄 Processing chat update for tab ${params.tabId}`) + + if (params.data?.messages) { + // First pass: process all system-prompt messages (diff content) + for (const message of params.data.messages) { + if (message.type === 'system-prompt' && message.messageId) { + await this.processDiffContent(message) + } + } + + // Second pass: process tool messages that might need the diff content + for (const message of params.data.messages) { + if (message.type === 'tool' && message.header?.fileList?.filePaths && message.messageId) { + await this.processFileListResult(message, params.tabId) + } + } + } + } + + /** + * Process file diff parameters directly from openFileDiff notification + */ + public async processFileDiff(params: { + originalFileUri: string + originalFileContent?: string + fileContent?: string + }): Promise { + getLogger().info(`[DiffAnimationHandler] 🎨 Processing file diff for: ${params.originalFileUri}`) + getLogger().info( + `[DiffAnimationHandler] 📏 Original content length: ${params.originalFileContent?.length || 0}` + ) + getLogger().info(`[DiffAnimationHandler] 📏 New content length: ${params.fileContent?.length || 0}`) + + try { + const filePath = await this.normalizeFilePath(params.originalFileUri) + if (!filePath) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${params.originalFileUri}`) + return + } + + // Try to open the document first to verify it exists + try { + const uri = vscode.Uri.file(filePath) + await vscode.workspace.openTextDocument(uri) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${filePath}, creating new file`) + // Create the directory if it doesn't exist + const directory = path.dirname(filePath) + await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) + } + + const originalContent = params.originalFileContent || '' + const newContent = params.fileContent || '' + + if (originalContent !== newContent) { + getLogger().info(`[DiffAnimationHandler] ✨ Content differs, starting diff animation`) + await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) + } else { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Original and new content are identical`) + } + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file diff: ${error}`) + } + } + + /** + * Process diff content from system-prompt messages + */ + private async processDiffContent(message: ChatMessage): Promise { + if (!message.messageId || !message.body) { + return + } + + if (message.messageId.endsWith('_original')) { + const toolUseId = message.messageId.replace('_original', '') + if (!this.diffContentMap.has(toolUseId)) { + this.diffContentMap.set(toolUseId, {}) + } + const diffData = this.diffContentMap.get(toolUseId)! + diffData.originalContent = message.body + getLogger().info( + `[DiffAnimationHandler] ✅ Found original content for ${toolUseId}, length: ${message.body.length}` + ) + + // If we already have new content, trigger animation immediately + if (diffData.newContent !== undefined) { + await this.triggerDiffAnimation(toolUseId) + } + } else if (message.messageId.endsWith('_new')) { + const toolUseId = message.messageId.replace('_new', '') + if (!this.diffContentMap.has(toolUseId)) { + this.diffContentMap.set(toolUseId, {}) + } + const diffData = this.diffContentMap.get(toolUseId)! + diffData.newContent = message.body + getLogger().info( + `[DiffAnimationHandler] ✅ Found new content for ${toolUseId}, length: ${message.body.length}` + ) + + // If we already have original content, trigger animation immediately + if (diffData.originalContent !== undefined) { + await this.triggerDiffAnimation(toolUseId) + } + } + } + + /** + * Process progress messages - open file immediately + */ + private async processProgressMessage(message: ChatMessage, tabId: string): Promise { + const fileList = message.header?.fileList + if (!fileList?.filePaths?.[0] || !fileList.details) { + return + } + + const fileName = fileList.filePaths[0] + const fileDetails = fileList.details[fileName] + if (!fileDetails?.description) { + return + } + + const filePath = await this.resolveFilePath(fileDetails.description) + if (!filePath) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not resolve path for: ${fileDetails.description}`) + return + } + + // Extract toolUseId from progress message + const toolUseId = message.messageId!.replace('progress_', '') + + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Opening file for toolUse ${toolUseId}: ${filePath}`) + + try { + // Open the file + const uri = vscode.Uri.file(filePath) + let document: vscode.TextDocument + let editor: vscode.TextEditor + let originalContent = '' + + try { + // Try to open existing file + document = await vscode.workspace.openTextDocument(uri) + originalContent = document.getText() + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) + getLogger().info(`[DiffAnimationHandler] ✅ Opened existing file: ${filePath}`) + } catch (error) { + // File doesn't exist - create new file + getLogger().info(`[DiffAnimationHandler] 🆕 Creating new file: ${filePath}`) + await vscode.workspace.fs.writeFile(uri, Buffer.from('')) + document = await vscode.workspace.openTextDocument(uri) + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) + } + + // Cache file info for later animation + this.activeFiles.set(toolUseId, { + editor, + originalContent, + currentContent: originalContent, + toolUseId, + }) + + // Cache original content + this.fileOriginalContentCache.set(filePath, originalContent) + + getLogger().info(`[DiffAnimationHandler] 📁 File ready for animation: ${filePath}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to open file: ${error}`) + } + } + + /** + * Process tool completion messages + */ + private async processToolCompleteMessage(message: ChatMessage, tabId: string): Promise { + if (!message.messageId) { + return + } + + getLogger().info(`[DiffAnimationHandler] đŸŽ¯ Processing tool complete message: ${message.messageId}`) + + // Skip if already processed + if (this.processedMessages.has(message.messageId)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Already processed: ${message.messageId}`) + return + } + this.processedMessages.add(message.messageId) + + // Trigger animation for this tool use + await this.triggerDiffAnimation(message.messageId) + } + + /** + * Trigger diff animation when both original and new content are ready + */ + private async triggerDiffAnimation(toolUseId: string): Promise { + getLogger().info(`[DiffAnimationHandler] 🎨 Triggering diff animation for toolUse: ${toolUseId}`) + + const diffData = this.diffContentMap.get(toolUseId) + const fileInfo = this.activeFiles.get(toolUseId) + + if (!diffData || !fileInfo) { + getLogger().warn( + `[DiffAnimationHandler] âš ī¸ Missing data for animation - diff: ${!!diffData}, file: ${!!fileInfo}` + ) + return + } + + if (diffData.originalContent === undefined || diffData.newContent === undefined) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Incomplete diff content`) + return + } + + const { editor } = fileInfo + const filePath = editor.document.uri.fsPath + + getLogger().info( + `[DiffAnimationHandler] đŸŽŦ Starting animation for ${filePath} - ` + + `original: ${diffData.originalContent.length} chars, new: ${diffData.newContent.length} chars` + ) + + try { + // Execute animation + await this.diffAnimationController.startDiffAnimation( + filePath, + diffData.originalContent, + diffData.newContent + ) + + // Update caches + fileInfo.currentContent = diffData.newContent + this.fileOriginalContentCache.set(filePath, diffData.newContent) + + // Cleanup + this.diffContentMap.delete(toolUseId) + this.activeFiles.delete(toolUseId) + + getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${filePath}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Animation failed: ${error}`) + } + } + + /** + * Process file list from ChatMessage with animation queuing + */ + private async processFileListResult(message: ChatMessage, tabId: string): Promise { + const fileList = message.header?.fileList + if (!fileList?.filePaths || !fileList.details || !message.messageId) { + return + } + + // Skip if already processed + if (this.processedMessages.has(message.messageId)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Skipping already processed message: ${message.messageId}`) + return + } + this.processedMessages.add(message.messageId) + + getLogger().info(`[DiffAnimationHandler] 📂 Processing fileList with messageId: ${message.messageId}`) + getLogger().info(`[DiffAnimationHandler] 📄 Files to process: ${fileList.filePaths.join(', ')}`) + + for (const fileName of fileList.filePaths) { + const fileDetails = fileList.details[fileName] + if (!fileDetails) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ No details for file: ${fileName}`) + continue + } + + const fullPath = fileDetails.description || fileName + getLogger().info(`[DiffAnimationHandler] 🔍 Resolving path: ${fullPath}`) + + const normalizedPath = await this.resolveFilePath(fullPath) + + if (!normalizedPath) { + getLogger().warn(`[DiffAnimationHandler] ❌ Could not resolve path for: ${fullPath}`) + continue + } + + getLogger().info(`[DiffAnimationHandler] ✅ Resolved to: ${normalizedPath}`) + getLogger().info( + `[DiffAnimationHandler] 📊 File changes: +${fileDetails.changes?.added || 0} -${fileDetails.changes?.deleted || 0}` + ) + + if (fileDetails.changes && (fileDetails.changes.added || fileDetails.changes.deleted)) { + // Queue the animation + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Queuing animation for: ${normalizedPath}`) + await this.queueAnimation(normalizedPath, fileName, fileDetails, message.messageId) + } else { + // For files without changes, just open them + getLogger().info(`[DiffAnimationHandler] 📖 Opening file without animation: ${normalizedPath}`) + try { + const uri = vscode.Uri.file(normalizedPath) + const document = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) + getLogger().info(`[DiffAnimationHandler] ✅ Opened file without changes: ${normalizedPath}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to open file: ${error}`) + } + } + } + } + + /** + * Queue animations to prevent conflicts + */ + private async queueAnimation( + filePath: string, + fileName: string, + fileDetails: any, + messageId: string + ): Promise { + return new Promise((resolve, reject) => { + const task: AnimationTask = { + filePath, + fileName, + fileDetails, + messageId, + resolve, + reject, + } + + this.animationQueue.push(task) + getLogger().info( + `[DiffAnimationHandler] đŸ“Ĩ Queued animation for ${filePath}, queue length: ${this.animationQueue.length}, isProcessing: ${this.isProcessingAnimation}` + ) + + // Process queue if not already processing + if (!this.isProcessingAnimation) { + getLogger().info(`[DiffAnimationHandler] 🏃 Starting animation queue processing`) + void this.processAnimationQueue() + } + }) + } + + /** + * Process animation queue sequentially + */ + private async processAnimationQueue(): Promise { + if (this.isProcessingAnimation || this.animationQueue.length === 0) { + getLogger().info( + `[DiffAnimationHandler] â­ī¸ Queue processing skipped - isProcessing: ${this.isProcessingAnimation}, queueLength: ${this.animationQueue.length}` + ) + return + } + + this.isProcessingAnimation = true + getLogger().info( + `[DiffAnimationHandler] đŸŽ¯ Starting queue processing, ${this.animationQueue.length} tasks in queue` + ) + + while (this.animationQueue.length > 0) { + const task = this.animationQueue.shift()! + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Processing animation task for: ${task.filePath}`) + + try { + await this.processFileWithAnimation(task.filePath, task.fileName, task.fileDetails, task.messageId) + task.resolve() + getLogger().info(`[DiffAnimationHandler] ✅ Animation task completed for: ${task.filePath}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Animation failed for ${task.filePath}: ${error}`) + task.reject(error) + } + + // Small delay between animations for better visibility + if (this.animationQueue.length > 0) { + getLogger().info(`[DiffAnimationHandler] âąī¸ Waiting 300ms before next animation`) + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } + + this.isProcessingAnimation = false + getLogger().info(`[DiffAnimationHandler] ✅ Queue processing completed`) + } + + /** + * Process file with animation (legacy method for queued animations) + */ + private async processFileWithAnimation( + normalizedPath: string, + fileName: string, + fileDetails: any, + messageId: string + ): Promise { + getLogger().info( + `[DiffAnimationHandler] 🎨 Starting animation for file: ${fileName}, path: ${normalizedPath}, messageId: ${messageId}, changes: +${fileDetails.changes.added} -${fileDetails.changes.deleted}` + ) + getLogger().info( + `[DiffAnimationHandler] 🔍 Available diff keys: ${Array.from(this.diffContentMap.keys()).join(', ')}` + ) + + this.animatingFiles.add(normalizedPath) + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Added to animatingFiles: ${normalizedPath}`) + + try { + // Open the file + const uri = vscode.Uri.file(normalizedPath) + let document: vscode.TextDocument + let editor: vscode.TextEditor + let isNewFile = false + let fileExistsBeforeOpen = false + + // Check if file exists + try { + await vscode.workspace.fs.stat(uri) + fileExistsBeforeOpen = true + getLogger().info(`[DiffAnimationHandler] 📄 File exists: ${normalizedPath}`) + } catch { + fileExistsBeforeOpen = false + getLogger().info(`[DiffAnimationHandler] 🆕 File does not exist: ${normalizedPath}`) + } + + try { + // Try to open existing file + document = await vscode.workspace.openTextDocument(uri) + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) + getLogger().info(`[DiffAnimationHandler] ✅ Opened existing file: ${normalizedPath}`) + } catch (error) { + // File doesn't exist - create new document + isNewFile = true + getLogger().info(`[DiffAnimationHandler] 🆕 File doesn't exist, creating new document: ${error}`) + + // For new files, use empty string as original content + this.fileOriginalContentCache.set(normalizedPath, '') + + // Create empty file first + await vscode.workspace.fs.writeFile(uri, Buffer.from('')) + + document = await vscode.workspace.openTextDocument(uri) + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) + + getLogger().info(`[DiffAnimationHandler] ✅ Created and opened new file: ${normalizedPath}`) + } + + // Get the current content + const currentContent = document.getText() + getLogger().info(`[DiffAnimationHandler] 📏 Current content length: ${currentContent.length}`) + + // Check if we have diff content from additionalMessages + // Try to find diff content using both the messageId directly and by checking for related IDs + let diffData = this.diffContentMap.get(messageId) + getLogger().info( + `[DiffAnimationHandler] 🔍 Looking for diff data with messageId: ${messageId}, found: ${!!diffData}` + ) + + // If not found directly, check if there are entries with _original/_new suffixes related to this messageId + if (!diffData || diffData.originalContent === undefined || diffData.newContent === undefined) { + getLogger().info(`[DiffAnimationHandler] 🔍 Direct lookup failed, trying alternative keys`) + + // Look for entries that might be related to this messageId (removing tool prefix) + const possibleBaseIds = [ + messageId.split('_')[0], // Try base ID without suffixes + messageId.replace('_tool', ''), // Try without _tool suffix + ] + + for (const [key, value] of this.diffContentMap.entries()) { + if ( + possibleBaseIds.some((id) => key.startsWith(id)) && + value.originalContent !== undefined && + value.newContent !== undefined + ) { + diffData = value + getLogger().info(`[DiffAnimationHandler] ✅ Found matching diff content with key: ${key}`) + break + } + } + } + + if (diffData && diffData.originalContent !== undefined && diffData.newContent !== undefined) { + getLogger().info( + `[DiffAnimationHandler] ✅ Using diff content from additionalMessages for messageId: ${messageId}` + ) + getLogger().info( + `[DiffAnimationHandler] 📊 Original: ${diffData.originalContent.length} chars, New: ${diffData.newContent.length} chars` + ) + + // Small delay to ensure editor is fully loaded + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Start the animation with content from additionalMessages + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting diff animation`) + await this.diffAnimationController.startDiffAnimation( + normalizedPath, + diffData.originalContent, + diffData.newContent + ) + + // Update cached original content AFTER animation completes + this.fileOriginalContentCache.set(normalizedPath, currentContent) + + // Clean up the diff content from map + this.diffContentMap.delete(messageId) + + getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${normalizedPath}`) + } else { + // Fallback to existing logic if no diff content from additionalMessages + getLogger().info( + `[DiffAnimationHandler] âš ī¸ No diff content from additionalMessages, using fallback logic` + ) + + // Get original content + let originalContent: string = '' + + // First, check if this was a new file that didn't exist before + if (!fileExistsBeforeOpen || isNewFile) { + originalContent = '' + getLogger().info(`[DiffAnimationHandler] 🆕 Using empty string as original content for new file`) + } else { + // Try to get from cache + originalContent = this.fileOriginalContentCache.get(normalizedPath) || '' + getLogger().info( + `[DiffAnimationHandler] 📋 Original content from cache: ${originalContent !== undefined ? 'found' : 'not found'}` + ) + } + + // Check if content actually changed + if (originalContent !== currentContent) { + getLogger().info( + `[DiffAnimationHandler] 🔄 Content changed - starting diff animation. Original: ${originalContent.length} chars, New: ${currentContent.length} chars` + ) + + // Small delay to ensure editor is fully loaded + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Start the animation + await this.diffAnimationController.startDiffAnimation( + normalizedPath, + originalContent, + currentContent + ) + + // Update cached original content AFTER animation completes + this.fileOriginalContentCache.set(normalizedPath, currentContent) + + getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${normalizedPath}`) + } else { + getLogger().warn( + `[DiffAnimationHandler] âš ī¸ No content change detected for: ${normalizedPath}. Original exists: ${originalContent !== undefined}, are same: ${originalContent === currentContent}` + ) + + // Show simple decoration as fallback + await this.showFallbackDecoration(editor, fileDetails) + } + } + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file: ${error}`) + } finally { + this.animatingFiles.delete(normalizedPath) + getLogger().info(`[DiffAnimationHandler] 🧹 Removed from animatingFiles: ${normalizedPath}`) + } + } + + /** + * Show fallback decoration when animation cannot be performed + */ + private async showFallbackDecoration(editor: vscode.TextEditor, fileDetails: any): Promise { + const changeDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: 'rgba(255, 255, 0, 0.1)', + after: { + contentText: ` 🔄 Modified by Amazon Q - ${fileDetails.changes.added || 0} additions, ${fileDetails.changes.deleted || 0} deletions`, + color: 'rgba(255, 255, 0, 0.7)', + fontStyle: 'italic', + }, + }) + + editor.setDecorations(changeDecoration, [new vscode.Range(0, 0, 0, 0)]) + + setTimeout(() => { + changeDecoration.dispose() + }, 5000) + } + + /** + * Resolve file path to absolute path + */ + private async resolveFilePath(filePath: string): Promise { + getLogger().info(`[DiffAnimationHandler] 🔍 Resolving file path: ${filePath}`) + + try { + // If already absolute, return as is + if (path.isAbsolute(filePath)) { + getLogger().info(`[DiffAnimationHandler] ✅ Path is already absolute: ${filePath}`) + return filePath + } + + // Try to resolve relative to workspace folders + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + getLogger().warn('[DiffAnimationHandler] âš ī¸ No workspace folders found') + return undefined + } + + getLogger().info(`[DiffAnimationHandler] 📁 Found ${workspaceFolders.length} workspace folders`) + + // Try each workspace folder + for (const folder of workspaceFolders) { + const absolutePath = path.join(folder.uri.fsPath, filePath) + getLogger().info(`[DiffAnimationHandler] 🔍 Trying: ${absolutePath}`) + + try { + // Check if file exists + await vscode.workspace.fs.stat(vscode.Uri.file(absolutePath)) + getLogger().info(`[DiffAnimationHandler] ✅ File exists, resolved ${filePath} to ${absolutePath}`) + return absolutePath + } catch { + getLogger().info(`[DiffAnimationHandler] ❌ File not found in: ${absolutePath}`) + // File doesn't exist in this workspace folder, try next + } + } + + // If file doesn't exist yet, return path relative to first workspace + const defaultPath = path.join(workspaceFolders[0].uri.fsPath, filePath) + getLogger().info(`[DiffAnimationHandler] 🆕 Using default path for new file: ${defaultPath}`) + return defaultPath + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Error resolving file path: ${error}`) + return undefined + } + } + + /** + * Normalize file path from URI or path string + */ + private async normalizeFilePath(pathOrUri: string): Promise { + getLogger().info(`[DiffAnimationHandler] 🔧 Normalizing path: ${pathOrUri}`) + + try { + // Check if it's already a file path + if (path.isAbsolute(pathOrUri)) { + getLogger().info(`[DiffAnimationHandler] ✅ Already absolute path: ${pathOrUri}`) + return pathOrUri + } + + // Handle file:// protocol explicitly + if (pathOrUri.startsWith('file://')) { + const fsPath = vscode.Uri.parse(pathOrUri).fsPath + getLogger().info(`[DiffAnimationHandler] ✅ Converted file:// URI to: ${fsPath}`) + return fsPath + } + + // Try to parse as URI + try { + const uri = vscode.Uri.parse(pathOrUri) + if (uri.scheme === 'file') { + getLogger().info(`[DiffAnimationHandler] ✅ Parsed as file URI: ${uri.fsPath}`) + return uri.fsPath + } + } catch { + getLogger().info(`[DiffAnimationHandler] âš ī¸ Not a valid URI, treating as path`) + // Invalid URI format, continue to fallback + } + + // Handle relative paths by resolving against workspace folders + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + // Try to find the file in any workspace folder + for (const folder of workspaceFolders) { + const possiblePath = path.join(folder.uri.fsPath, pathOrUri) + try { + await vscode.workspace.fs.stat(vscode.Uri.file(possiblePath)) + getLogger().info(`[DiffAnimationHandler] ✅ Resolved relative path to: ${possiblePath}`) + return possiblePath + } catch { + // File doesn't exist in this workspace, continue to next + } + } + + // If not found, default to first workspace + const defaultPath = path.join(workspaceFolders[0].uri.fsPath, pathOrUri) + getLogger().info(`[DiffAnimationHandler] 🆕 Using default workspace path: ${defaultPath}`) + return defaultPath + } + + // Fallback: treat as path + getLogger().info(`[DiffAnimationHandler] âš ī¸ Using as-is: ${pathOrUri}`) + return pathOrUri + } catch (error: any) { + getLogger().error(`[DiffAnimationHandler] ❌ Error normalizing file path: ${error}`) + return pathOrUri + } + } + + /** + * Clear caches for a specific tab (useful when conversation ends) + */ + public clearTabCache(tabId: string): void { + // Clear processed messages for the tab + this.processedMessages.clear() + getLogger().info(`[DiffAnimationHandler] 🧹 Cleared cache for tab ${tabId}`) + } + + /** + * Clear animating files (for error recovery) + */ + public clearAnimatingFiles(): void { + getLogger().info( + `[DiffAnimationHandler] 🧹 Clearing all animating files: ${Array.from(this.animatingFiles).join(', ')}` + ) + this.animatingFiles.clear() + } + + public dispose(): void { + getLogger().info(`[DiffAnimationHandler] đŸ’Ĩ Disposing DiffAnimationHandler`) + this.fileChangeCache.clear() + this.diffContentMap.clear() + this.fileOriginalContentCache.clear() + this.processedMessages.clear() + this.animatingFiles.clear() + this.activeFiles.clear() + this.animationQueue = [] + this.diffAnimationController.dispose() + + // Dispose all event listeners + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables = [] + } +} From 3b9ed72a0e4c78381e4c7f70e4fb7be6126713b4 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Sun, 8 Jun 2025 23:19:39 -0700 Subject: [PATCH 06/32] Render the diff animation totally based on frontend --- .../diffAnimation/diffAnimationHandler.ts | 855 ++++-------------- .../amazonq/src/lsp/chat/diffController.ts | 75 -- packages/amazonq/src/lsp/chat/messages.ts | 372 +++----- 3 files changed, 320 insertions(+), 982 deletions(-) delete mode 100644 packages/amazonq/src/lsp/chat/diffController.ts diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index 48ad51a8cbe..752a0155e35 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -9,147 +9,80 @@ import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server- import { getLogger } from 'aws-core-vscode/shared' import { DiffAnimationController } from './diffAnimationController' -interface FileChangeInfo { +interface PendingFileWrite { filePath: string - fileName: string - originalContent?: string - newContent?: string - messageId: string - changes?: { - added?: number - deleted?: number - } -} - -interface AnimationTask { - filePath: string - fileName: string - fileDetails: any - messageId: string - resolve: () => void - reject: (error: any) => void + originalContent: string + toolUseId: string + timestamp: number } export class DiffAnimationHandler implements vscode.Disposable { private diffAnimationController: DiffAnimationController - private fileChangeCache = new Map() - // Store diff content by toolUseId - private diffContentMap = new Map() - // Add a new cache to store the original content of files - private fileOriginalContentCache = new Map() private disposables: vscode.Disposable[] = [] - // Animation queue to prevent conflicts - private animationQueue: AnimationTask[] = [] - private isProcessingAnimation = false - // Track files currently being animated + // Track pending file writes by file path + private pendingWrites = new Map() + // Track which files are being animated private animatingFiles = new Set() - // Track processed message IDs to avoid duplicates + // Track processed messages to avoid duplicates private processedMessages = new Set() - - // Track active files for real-time processing - private activeFiles = new Map< - string, - { - editor: vscode.TextEditor - originalContent: string - currentContent: string - toolUseId: string - } - >() + // File system watcher + private fileWatcher: vscode.FileSystemWatcher | undefined constructor() { getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler`) this.diffAnimationController = new DiffAnimationController() - // Listen to file open events and cache original content - const openTextDocumentDisposable = vscode.workspace.onDidOpenTextDocument((document) => { - const filePath = document.uri.fsPath - if (!this.fileOriginalContentCache.has(filePath) && document.uri.scheme === 'file') { - getLogger().info(`[DiffAnimationHandler] 📄 Caching original content for: ${filePath}`) - this.fileOriginalContentCache.set(filePath, document.getText()) - } + // Set up file system watcher for all files + this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*') + + // Watch for file changes + this.fileWatcher.onDidChange(async (uri) => { + await this.handleFileChange(uri) }) - this.disposables.push(openTextDocumentDisposable) - // Listen to file change events - const changeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument((event) => { - if (event.document.uri.scheme !== 'file') { - return - } + // Watch for file creation + this.fileWatcher.onDidCreate(async (uri) => { + await this.handleFileChange(uri) + }) - const filePath = event.document.uri.fsPath - getLogger().info(`[DiffAnimationHandler] 📝 File change detected: ${filePath}`) + this.disposables.push(this.fileWatcher) - // Skip if file is currently being animated - if (this.animatingFiles.has(filePath)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Skipping change event for animating file: ${filePath}`) + // Also listen to text document changes for more immediate detection + const changeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument(async (event) => { + if (event.document.uri.scheme !== 'file' || event.contentChanges.length === 0) { return } - const currentContent = event.document.getText() - - // Check if we have cached original content - const originalContent = this.fileOriginalContentCache.get(filePath) - if ( - originalContent !== undefined && - originalContent !== currentContent && - event.contentChanges.length > 0 - ) { - getLogger().info(`[DiffAnimationHandler] 🔄 Detected change in file: ${filePath}`) - // Update diff content mapping - this.diffContentMap.set(filePath, { - originalContent: originalContent, - newContent: currentContent, - filePath: filePath, - }) + // Check if this is an external change (not from user typing) + if (event.reason === undefined) { + await this.handleFileChange(event.document.uri) } }) this.disposables.push(changeTextDocumentDisposable) } /** - * Process streaming ChatResult updates - supports real-time animation + * Process streaming ChatResult updates */ - public async processChatResult(chatResult: ChatResult, tabId: string, isPartialResult?: boolean): Promise { + public async processChatResult( + chatResult: ChatResult | ChatMessage, + tabId: string, + isPartialResult?: boolean + ): Promise { getLogger().info( `[DiffAnimationHandler] 📨 Processing ChatResult for tab ${tabId}, isPartial: ${isPartialResult}` ) - getLogger().info( - `[DiffAnimationHandler] 📊 ChatResult details: messageId=${chatResult.messageId}, additionalMessagesCount=${chatResult.additionalMessages?.length || 0}` - ) try { - // Always process additional messages - if (chatResult.additionalMessages) { - getLogger().info( - `[DiffAnimationHandler] 📋 Processing ${chatResult.additionalMessages.length} additional messages` - ) - + // Handle both ChatResult and ChatMessage types + if ('type' in chatResult && chatResult.type === 'tool') { + // This is a ChatMessage + await this.processChatMessage(chatResult as ChatMessage, tabId) + } else if ('additionalMessages' in chatResult && chatResult.additionalMessages) { + // This is a ChatResult with additional messages for (const message of chatResult.additionalMessages) { - getLogger().info( - `[DiffAnimationHandler] 📌 Message: type=${message.type}, messageId=${message.messageId}` - ) - - // 1. Process diff content (system-prompt) - if (message.type === 'system-prompt' && message.messageId) { - await this.processDiffContent(message) - } - - // 2. Process progress messages (progress_) - open file immediately - if (message.type === 'tool' && message.messageId?.startsWith('progress_')) { - await this.processProgressMessage(message, tabId) - } - - // 3. Process tool completion messages - trigger animation - if ( - message.type === 'tool' && - message.messageId && - !message.messageId.startsWith('progress_') && - message.header?.fileList - ) { - await this.processToolCompleteMessage(message, tabId) - } + await this.processChatMessage(message, tabId) } } } catch (error) { @@ -158,594 +91,226 @@ export class DiffAnimationHandler implements vscode.Disposable { } /** - * Process ChatUpdateParams for file content updates + * Process individual chat messages */ - public async processChatUpdate(params: ChatUpdateParams): Promise { - getLogger().info(`[DiffAnimationHandler] 🔄 Processing chat update for tab ${params.tabId}`) - - if (params.data?.messages) { - // First pass: process all system-prompt messages (diff content) - for (const message of params.data.messages) { - if (message.type === 'system-prompt' && message.messageId) { - await this.processDiffContent(message) - } - } - - // Second pass: process tool messages that might need the diff content - for (const message of params.data.messages) { - if (message.type === 'tool' && message.header?.fileList?.filePaths && message.messageId) { - await this.processFileListResult(message, params.tabId) - } - } - } - } - - /** - * Process file diff parameters directly from openFileDiff notification - */ - public async processFileDiff(params: { - originalFileUri: string - originalFileContent?: string - fileContent?: string - }): Promise { - getLogger().info(`[DiffAnimationHandler] 🎨 Processing file diff for: ${params.originalFileUri}`) - getLogger().info( - `[DiffAnimationHandler] 📏 Original content length: ${params.originalFileContent?.length || 0}` - ) - getLogger().info(`[DiffAnimationHandler] 📏 New content length: ${params.fileContent?.length || 0}`) - - try { - const filePath = await this.normalizeFilePath(params.originalFileUri) - if (!filePath) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${params.originalFileUri}`) - return - } - - // Try to open the document first to verify it exists - try { - const uri = vscode.Uri.file(filePath) - await vscode.workspace.openTextDocument(uri) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${filePath}, creating new file`) - // Create the directory if it doesn't exist - const directory = path.dirname(filePath) - await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) - } - - const originalContent = params.originalFileContent || '' - const newContent = params.fileContent || '' - - if (originalContent !== newContent) { - getLogger().info(`[DiffAnimationHandler] ✨ Content differs, starting diff animation`) - await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) - } else { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Original and new content are identical`) - } - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file diff: ${error}`) - } - } - - /** - * Process diff content from system-prompt messages - */ - private async processDiffContent(message: ChatMessage): Promise { - if (!message.messageId || !message.body) { + private async processChatMessage(message: ChatMessage, tabId: string): Promise { + if (!message.messageId) { return } - if (message.messageId.endsWith('_original')) { - const toolUseId = message.messageId.replace('_original', '') - if (!this.diffContentMap.has(toolUseId)) { - this.diffContentMap.set(toolUseId, {}) - } - const diffData = this.diffContentMap.get(toolUseId)! - diffData.originalContent = message.body - getLogger().info( - `[DiffAnimationHandler] ✅ Found original content for ${toolUseId}, length: ${message.body.length}` - ) - - // If we already have new content, trigger animation immediately - if (diffData.newContent !== undefined) { - await this.triggerDiffAnimation(toolUseId) - } - } else if (message.messageId.endsWith('_new')) { - const toolUseId = message.messageId.replace('_new', '') - if (!this.diffContentMap.has(toolUseId)) { - this.diffContentMap.set(toolUseId, {}) - } - const diffData = this.diffContentMap.get(toolUseId)! - diffData.newContent = message.body - getLogger().info( - `[DiffAnimationHandler] ✅ Found new content for ${toolUseId}, length: ${message.body.length}` - ) - - // If we already have original content, trigger animation immediately - if (diffData.originalContent !== undefined) { - await this.triggerDiffAnimation(toolUseId) - } + // Check for fsWrite tool preparation (when tool is about to execute) + if (message.type === 'tool' && message.messageId.startsWith('progress_')) { + await this.processFsWritePreparation(message, tabId) } } /** - * Process progress messages - open file immediately + * Process fsWrite preparation - capture content BEFORE file is written */ - private async processProgressMessage(message: ChatMessage, tabId: string): Promise { + private async processFsWritePreparation(message: ChatMessage, tabId: string): Promise { const fileList = message.header?.fileList - if (!fileList?.filePaths?.[0] || !fileList.details) { + if (!fileList?.filePaths || fileList.filePaths.length === 0) { return } const fileName = fileList.filePaths[0] - const fileDetails = fileList.details[fileName] + const fileDetails = fileList.details?.[fileName] + if (!fileDetails?.description) { return } const filePath = await this.resolveFilePath(fileDetails.description) if (!filePath) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not resolve path for: ${fileDetails.description}`) return } // Extract toolUseId from progress message const toolUseId = message.messageId!.replace('progress_', '') - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Opening file for toolUse ${toolUseId}: ${filePath}`) + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Preparing for fsWrite: ${filePath} (toolUse: ${toolUseId})`) + + // Capture current content IMMEDIATELY before the write happens + let originalContent = '' + let fileExists = false try { - // Open the file const uri = vscode.Uri.file(filePath) - let document: vscode.TextDocument - let editor: vscode.TextEditor - let originalContent = '' - - try { - // Try to open existing file - document = await vscode.workspace.openTextDocument(uri) - originalContent = document.getText() - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }) - getLogger().info(`[DiffAnimationHandler] ✅ Opened existing file: ${filePath}`) - } catch (error) { - // File doesn't exist - create new file - getLogger().info(`[DiffAnimationHandler] 🆕 Creating new file: ${filePath}`) - await vscode.workspace.fs.writeFile(uri, Buffer.from('')) - document = await vscode.workspace.openTextDocument(uri) - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }) - } - - // Cache file info for later animation - this.activeFiles.set(toolUseId, { - editor, - originalContent, - currentContent: originalContent, - toolUseId, - }) - - // Cache original content - this.fileOriginalContentCache.set(filePath, originalContent) - - getLogger().info(`[DiffAnimationHandler] 📁 File ready for animation: ${filePath}`) + const document = await vscode.workspace.openTextDocument(uri) + originalContent = document.getText() + fileExists = true + getLogger().info(`[DiffAnimationHandler] 📸 Captured existing content: ${originalContent.length} chars`) } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to open file: ${error}`) - } - } - - /** - * Process tool completion messages - */ - private async processToolCompleteMessage(message: ChatMessage, tabId: string): Promise { - if (!message.messageId) { - return - } - - getLogger().info(`[DiffAnimationHandler] đŸŽ¯ Processing tool complete message: ${message.messageId}`) - - // Skip if already processed - if (this.processedMessages.has(message.messageId)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Already processed: ${message.messageId}`) - return - } - this.processedMessages.add(message.messageId) - - // Trigger animation for this tool use - await this.triggerDiffAnimation(message.messageId) - } - - /** - * Trigger diff animation when both original and new content are ready - */ - private async triggerDiffAnimation(toolUseId: string): Promise { - getLogger().info(`[DiffAnimationHandler] 🎨 Triggering diff animation for toolUse: ${toolUseId}`) - - const diffData = this.diffContentMap.get(toolUseId) - const fileInfo = this.activeFiles.get(toolUseId) - - if (!diffData || !fileInfo) { - getLogger().warn( - `[DiffAnimationHandler] âš ī¸ Missing data for animation - diff: ${!!diffData}, file: ${!!fileInfo}` - ) - return + // File doesn't exist yet + getLogger().info(`[DiffAnimationHandler] 🆕 File doesn't exist yet: ${filePath}`) + originalContent = '' } - if (diffData.originalContent === undefined || diffData.newContent === undefined) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Incomplete diff content`) - return - } - - const { editor } = fileInfo - const filePath = editor.document.uri.fsPath - - getLogger().info( - `[DiffAnimationHandler] đŸŽŦ Starting animation for ${filePath} - ` + - `original: ${diffData.originalContent.length} chars, new: ${diffData.newContent.length} chars` - ) + // Store pending write info + this.pendingWrites.set(filePath, { + filePath, + originalContent, + toolUseId, + timestamp: Date.now(), + }) + // Open/create the file to make it visible try { - // Execute animation - await this.diffAnimationController.startDiffAnimation( - filePath, - diffData.originalContent, - diffData.newContent - ) + const uri = vscode.Uri.file(filePath) - // Update caches - fileInfo.currentContent = diffData.newContent - this.fileOriginalContentCache.set(filePath, diffData.newContent) + if (!fileExists) { + // Create directory if needed + const directory = path.dirname(filePath) + await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) + // Create empty file + await vscode.workspace.fs.writeFile(uri, Buffer.from('')) + } - // Cleanup - this.diffContentMap.delete(toolUseId) - this.activeFiles.delete(toolUseId) + // Open the document + const document = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }) - getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${filePath}`) + getLogger().info(`[DiffAnimationHandler] ✅ File opened and ready: ${filePath}`) } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Animation failed: ${error}`) + getLogger().error(`[DiffAnimationHandler] ❌ Failed to prepare file: ${error}`) } } /** - * Process file list from ChatMessage with animation queuing + * Handle file changes - this is where we detect the actual write */ - private async processFileListResult(message: ChatMessage, tabId: string): Promise { - const fileList = message.header?.fileList - if (!fileList?.filePaths || !fileList.details || !message.messageId) { - return - } + private async handleFileChange(uri: vscode.Uri): Promise { + const filePath = uri.fsPath - // Skip if already processed - if (this.processedMessages.has(message.messageId)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Skipping already processed message: ${message.messageId}`) + // Check if we have a pending write for this file + const pendingWrite = this.pendingWrites.get(filePath) + if (!pendingWrite) { return } - this.processedMessages.add(message.messageId) - - getLogger().info(`[DiffAnimationHandler] 📂 Processing fileList with messageId: ${message.messageId}`) - getLogger().info(`[DiffAnimationHandler] 📄 Files to process: ${fileList.filePaths.join(', ')}`) - for (const fileName of fileList.filePaths) { - const fileDetails = fileList.details[fileName] - if (!fileDetails) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ No details for file: ${fileName}`) - continue - } + // Remove from pending writes + this.pendingWrites.delete(filePath) - const fullPath = fileDetails.description || fileName - getLogger().info(`[DiffAnimationHandler] 🔍 Resolving path: ${fullPath}`) + getLogger().info(`[DiffAnimationHandler] 📝 Detected file write: ${filePath}`) - const normalizedPath = await this.resolveFilePath(fullPath) + // Small delay to ensure the write is complete + await new Promise((resolve) => setTimeout(resolve, 50)) - if (!normalizedPath) { - getLogger().warn(`[DiffAnimationHandler] ❌ Could not resolve path for: ${fullPath}`) - continue - } + try { + // Read the new content + const document = await vscode.workspace.openTextDocument(uri) + const newContent = document.getText() - getLogger().info(`[DiffAnimationHandler] ✅ Resolved to: ${normalizedPath}`) - getLogger().info( - `[DiffAnimationHandler] 📊 File changes: +${fileDetails.changes?.added || 0} -${fileDetails.changes?.deleted || 0}` - ) + // Check if content actually changed + if (pendingWrite.originalContent !== newContent) { + getLogger().info( + `[DiffAnimationHandler] đŸŽŦ Content changed, starting animation - ` + + `original: ${pendingWrite.originalContent.length} chars, new: ${newContent.length} chars` + ) - if (fileDetails.changes && (fileDetails.changes.added || fileDetails.changes.deleted)) { - // Queue the animation - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Queuing animation for: ${normalizedPath}`) - await this.queueAnimation(normalizedPath, fileName, fileDetails, message.messageId) + // Start the animation + await this.animateFileChange(filePath, pendingWrite.originalContent, newContent, pendingWrite.toolUseId) } else { - // For files without changes, just open them - getLogger().info(`[DiffAnimationHandler] 📖 Opening file without animation: ${normalizedPath}`) - try { - const uri = vscode.Uri.file(normalizedPath) - const document = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }) - getLogger().info(`[DiffAnimationHandler] ✅ Opened file without changes: ${normalizedPath}`) - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to open file: ${error}`) - } + getLogger().info(`[DiffAnimationHandler] â„šī¸ No content change for: ${filePath}`) } + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file change: ${error}`) } } /** - * Queue animations to prevent conflicts + * Animate file changes */ - private async queueAnimation( + private async animateFileChange( filePath: string, - fileName: string, - fileDetails: any, - messageId: string + originalContent: string, + newContent: string, + toolUseId: string ): Promise { - return new Promise((resolve, reject) => { - const task: AnimationTask = { - filePath, - fileName, - fileDetails, - messageId, - resolve, - reject, - } - - this.animationQueue.push(task) - getLogger().info( - `[DiffAnimationHandler] đŸ“Ĩ Queued animation for ${filePath}, queue length: ${this.animationQueue.length}, isProcessing: ${this.isProcessingAnimation}` - ) - - // Process queue if not already processing - if (!this.isProcessingAnimation) { - getLogger().info(`[DiffAnimationHandler] 🏃 Starting animation queue processing`) - void this.processAnimationQueue() - } - }) - } - - /** - * Process animation queue sequentially - */ - private async processAnimationQueue(): Promise { - if (this.isProcessingAnimation || this.animationQueue.length === 0) { - getLogger().info( - `[DiffAnimationHandler] â­ī¸ Queue processing skipped - isProcessing: ${this.isProcessingAnimation}, queueLength: ${this.animationQueue.length}` - ) + if (this.animatingFiles.has(filePath)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Already animating: ${filePath}`) return } - this.isProcessingAnimation = true - getLogger().info( - `[DiffAnimationHandler] đŸŽ¯ Starting queue processing, ${this.animationQueue.length} tasks in queue` - ) + this.animatingFiles.add(filePath) - while (this.animationQueue.length > 0) { - const task = this.animationQueue.shift()! - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Processing animation task for: ${task.filePath}`) + try { + getLogger().info(`[DiffAnimationHandler] 🔄 Animating file change: ${filePath}`) + // Open the file try { - await this.processFileWithAnimation(task.filePath, task.fileName, task.fileDetails, task.messageId) - task.resolve() - getLogger().info(`[DiffAnimationHandler] ✅ Animation task completed for: ${task.filePath}`) + const uri = vscode.Uri.file(filePath) + const document = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(document, { preview: false }) } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Animation failed for ${task.filePath}: ${error}`) - task.reject(error) + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${error}`) } - // Small delay between animations for better visibility - if (this.animationQueue.length > 0) { - getLogger().info(`[DiffAnimationHandler] âąī¸ Waiting 300ms before next animation`) - await new Promise((resolve) => setTimeout(resolve, 300)) - } - } + // Start the animation + await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) - this.isProcessingAnimation = false - getLogger().info(`[DiffAnimationHandler] ✅ Queue processing completed`) + getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${filePath}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate: ${error}`) + } finally { + this.animatingFiles.delete(filePath) + } } /** - * Process file with animation (legacy method for queued animations) + * Process file diff parameters directly (for backwards compatibility) */ - private async processFileWithAnimation( - normalizedPath: string, - fileName: string, - fileDetails: any, - messageId: string - ): Promise { - getLogger().info( - `[DiffAnimationHandler] 🎨 Starting animation for file: ${fileName}, path: ${normalizedPath}, messageId: ${messageId}, changes: +${fileDetails.changes.added} -${fileDetails.changes.deleted}` - ) - getLogger().info( - `[DiffAnimationHandler] 🔍 Available diff keys: ${Array.from(this.diffContentMap.keys()).join(', ')}` - ) - - this.animatingFiles.add(normalizedPath) - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Added to animatingFiles: ${normalizedPath}`) + public async processFileDiff(params: { + originalFileUri: string + originalFileContent?: string + fileContent?: string + }): Promise { + getLogger().info(`[DiffAnimationHandler] 🎨 Processing file diff for: ${params.originalFileUri}`) try { - // Open the file - const uri = vscode.Uri.file(normalizedPath) - let document: vscode.TextDocument - let editor: vscode.TextEditor - let isNewFile = false - let fileExistsBeforeOpen = false - - // Check if file exists - try { - await vscode.workspace.fs.stat(uri) - fileExistsBeforeOpen = true - getLogger().info(`[DiffAnimationHandler] 📄 File exists: ${normalizedPath}`) - } catch { - fileExistsBeforeOpen = false - getLogger().info(`[DiffAnimationHandler] 🆕 File does not exist: ${normalizedPath}`) + const filePath = await this.normalizeFilePath(params.originalFileUri) + if (!filePath) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${params.originalFileUri}`) + return } - try { - // Try to open existing file - document = await vscode.workspace.openTextDocument(uri) - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }) - getLogger().info(`[DiffAnimationHandler] ✅ Opened existing file: ${normalizedPath}`) - } catch (error) { - // File doesn't exist - create new document - isNewFile = true - getLogger().info(`[DiffAnimationHandler] 🆕 File doesn't exist, creating new document: ${error}`) - - // For new files, use empty string as original content - this.fileOriginalContentCache.set(normalizedPath, '') - - // Create empty file first - await vscode.workspace.fs.writeFile(uri, Buffer.from('')) - - document = await vscode.workspace.openTextDocument(uri) - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - }) + const originalContent = params.originalFileContent || '' + const newContent = params.fileContent || '' - getLogger().info(`[DiffAnimationHandler] ✅ Created and opened new file: ${normalizedPath}`) - } + if (originalContent !== newContent) { + getLogger().info(`[DiffAnimationHandler] ✨ Content differs, starting diff animation`) - // Get the current content - const currentContent = document.getText() - getLogger().info(`[DiffAnimationHandler] 📏 Current content length: ${currentContent.length}`) - - // Check if we have diff content from additionalMessages - // Try to find diff content using both the messageId directly and by checking for related IDs - let diffData = this.diffContentMap.get(messageId) - getLogger().info( - `[DiffAnimationHandler] 🔍 Looking for diff data with messageId: ${messageId}, found: ${!!diffData}` - ) - - // If not found directly, check if there are entries with _original/_new suffixes related to this messageId - if (!diffData || diffData.originalContent === undefined || diffData.newContent === undefined) { - getLogger().info(`[DiffAnimationHandler] 🔍 Direct lookup failed, trying alternative keys`) - - // Look for entries that might be related to this messageId (removing tool prefix) - const possibleBaseIds = [ - messageId.split('_')[0], // Try base ID without suffixes - messageId.replace('_tool', ''), // Try without _tool suffix - ] - - for (const [key, value] of this.diffContentMap.entries()) { - if ( - possibleBaseIds.some((id) => key.startsWith(id)) && - value.originalContent !== undefined && - value.newContent !== undefined - ) { - diffData = value - getLogger().info(`[DiffAnimationHandler] ✅ Found matching diff content with key: ${key}`) - break - } + // Open the file first + try { + const uri = vscode.Uri.file(filePath) + await vscode.window.showTextDocument(uri, { preview: false }) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${error}`) } - } - - if (diffData && diffData.originalContent !== undefined && diffData.newContent !== undefined) { - getLogger().info( - `[DiffAnimationHandler] ✅ Using diff content from additionalMessages for messageId: ${messageId}` - ) - getLogger().info( - `[DiffAnimationHandler] 📊 Original: ${diffData.originalContent.length} chars, New: ${diffData.newContent.length} chars` - ) - - // Small delay to ensure editor is fully loaded - await new Promise((resolve) => setTimeout(resolve, 100)) - // Start the animation with content from additionalMessages - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting diff animation`) - await this.diffAnimationController.startDiffAnimation( - normalizedPath, - diffData.originalContent, - diffData.newContent - ) - - // Update cached original content AFTER animation completes - this.fileOriginalContentCache.set(normalizedPath, currentContent) - - // Clean up the diff content from map - this.diffContentMap.delete(messageId) - - getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${normalizedPath}`) + await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) } else { - // Fallback to existing logic if no diff content from additionalMessages - getLogger().info( - `[DiffAnimationHandler] âš ī¸ No diff content from additionalMessages, using fallback logic` - ) - - // Get original content - let originalContent: string = '' - - // First, check if this was a new file that didn't exist before - if (!fileExistsBeforeOpen || isNewFile) { - originalContent = '' - getLogger().info(`[DiffAnimationHandler] 🆕 Using empty string as original content for new file`) - } else { - // Try to get from cache - originalContent = this.fileOriginalContentCache.get(normalizedPath) || '' - getLogger().info( - `[DiffAnimationHandler] 📋 Original content from cache: ${originalContent !== undefined ? 'found' : 'not found'}` - ) - } - - // Check if content actually changed - if (originalContent !== currentContent) { - getLogger().info( - `[DiffAnimationHandler] 🔄 Content changed - starting diff animation. Original: ${originalContent.length} chars, New: ${currentContent.length} chars` - ) - - // Small delay to ensure editor is fully loaded - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Start the animation - await this.diffAnimationController.startDiffAnimation( - normalizedPath, - originalContent, - currentContent - ) - - // Update cached original content AFTER animation completes - this.fileOriginalContentCache.set(normalizedPath, currentContent) - - getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${normalizedPath}`) - } else { - getLogger().warn( - `[DiffAnimationHandler] âš ī¸ No content change detected for: ${normalizedPath}. Original exists: ${originalContent !== undefined}, are same: ${originalContent === currentContent}` - ) - - // Show simple decoration as fallback - await this.showFallbackDecoration(editor, fileDetails) - } + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Original and new content are identical`) } } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file: ${error}`) - } finally { - this.animatingFiles.delete(normalizedPath) - getLogger().info(`[DiffAnimationHandler] 🧹 Removed from animatingFiles: ${normalizedPath}`) + getLogger().error(`[DiffAnimationHandler] ❌ Failed to process file diff: ${error}`) } } /** - * Show fallback decoration when animation cannot be performed + * Process ChatUpdateParams */ - private async showFallbackDecoration(editor: vscode.TextEditor, fileDetails: any): Promise { - const changeDecoration = vscode.window.createTextEditorDecorationType({ - isWholeLine: true, - backgroundColor: 'rgba(255, 255, 0, 0.1)', - after: { - contentText: ` 🔄 Modified by Amazon Q - ${fileDetails.changes.added || 0} additions, ${fileDetails.changes.deleted || 0} deletions`, - color: 'rgba(255, 255, 0, 0.7)', - fontStyle: 'italic', - }, - }) - - editor.setDecorations(changeDecoration, [new vscode.Range(0, 0, 0, 0)]) + public async processChatUpdate(params: ChatUpdateParams): Promise { + getLogger().info(`[DiffAnimationHandler] 🔄 Processing chat update for tab ${params.tabId}`) - setTimeout(() => { - changeDecoration.dispose() - }, 5000) + if (params.data?.messages) { + for (const message of params.data.messages) { + await this.processChatMessage(message, params.tabId) + } + } } /** @@ -765,23 +330,19 @@ export class DiffAnimationHandler implements vscode.Disposable { const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders || workspaceFolders.length === 0) { getLogger().warn('[DiffAnimationHandler] âš ī¸ No workspace folders found') - return undefined + return filePath } - getLogger().info(`[DiffAnimationHandler] 📁 Found ${workspaceFolders.length} workspace folders`) - // Try each workspace folder for (const folder of workspaceFolders) { const absolutePath = path.join(folder.uri.fsPath, filePath) getLogger().info(`[DiffAnimationHandler] 🔍 Trying: ${absolutePath}`) try { - // Check if file exists await vscode.workspace.fs.stat(vscode.Uri.file(absolutePath)) - getLogger().info(`[DiffAnimationHandler] ✅ File exists, resolved ${filePath} to ${absolutePath}`) + getLogger().info(`[DiffAnimationHandler] ✅ File exists at: ${absolutePath}`) return absolutePath } catch { - getLogger().info(`[DiffAnimationHandler] ❌ File not found in: ${absolutePath}`) // File doesn't exist in this workspace folder, try next } } @@ -803,19 +364,19 @@ export class DiffAnimationHandler implements vscode.Disposable { getLogger().info(`[DiffAnimationHandler] 🔧 Normalizing path: ${pathOrUri}`) try { - // Check if it's already a file path - if (path.isAbsolute(pathOrUri)) { - getLogger().info(`[DiffAnimationHandler] ✅ Already absolute path: ${pathOrUri}`) - return pathOrUri - } - - // Handle file:// protocol explicitly + // Handle file:// protocol if (pathOrUri.startsWith('file://')) { const fsPath = vscode.Uri.parse(pathOrUri).fsPath getLogger().info(`[DiffAnimationHandler] ✅ Converted file:// URI to: ${fsPath}`) return fsPath } + // Check if it's already a file path + if (path.isAbsolute(pathOrUri)) { + getLogger().info(`[DiffAnimationHandler] ✅ Already absolute path: ${pathOrUri}`) + return pathOrUri + } + // Try to parse as URI try { const uri = vscode.Uri.parse(pathOrUri) @@ -824,70 +385,46 @@ export class DiffAnimationHandler implements vscode.Disposable { return uri.fsPath } } catch { - getLogger().info(`[DiffAnimationHandler] âš ī¸ Not a valid URI, treating as path`) - // Invalid URI format, continue to fallback + // Not a valid URI, treat as path } - // Handle relative paths by resolving against workspace folders - const workspaceFolders = vscode.workspace.workspaceFolders - if (workspaceFolders && workspaceFolders.length > 0) { - // Try to find the file in any workspace folder - for (const folder of workspaceFolders) { - const possiblePath = path.join(folder.uri.fsPath, pathOrUri) - try { - await vscode.workspace.fs.stat(vscode.Uri.file(possiblePath)) - getLogger().info(`[DiffAnimationHandler] ✅ Resolved relative path to: ${possiblePath}`) - return possiblePath - } catch { - // File doesn't exist in this workspace, continue to next - } - } - - // If not found, default to first workspace - const defaultPath = path.join(workspaceFolders[0].uri.fsPath, pathOrUri) - getLogger().info(`[DiffAnimationHandler] 🆕 Using default workspace path: ${defaultPath}`) - return defaultPath - } - - // Fallback: treat as path + // Return as-is if we can't normalize getLogger().info(`[DiffAnimationHandler] âš ī¸ Using as-is: ${pathOrUri}`) return pathOrUri - } catch (error: any) { + } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Error normalizing file path: ${error}`) return pathOrUri } } /** - * Clear caches for a specific tab (useful when conversation ends) + * Clear caches for a specific tab */ public clearTabCache(tabId: string): void { - // Clear processed messages for the tab - this.processedMessages.clear() - getLogger().info(`[DiffAnimationHandler] 🧹 Cleared cache for tab ${tabId}`) - } + // Clean up old pending writes (older than 5 minutes) + const now = Date.now() + const timeout = 5 * 60 * 1000 // 5 minutes - /** - * Clear animating files (for error recovery) - */ - public clearAnimatingFiles(): void { - getLogger().info( - `[DiffAnimationHandler] 🧹 Clearing all animating files: ${Array.from(this.animatingFiles).join(', ')}` - ) - this.animatingFiles.clear() + for (const [filePath, write] of this.pendingWrites) { + if (now - write.timestamp > timeout) { + this.pendingWrites.delete(filePath) + } + } + + getLogger().info(`[DiffAnimationHandler] 🧹 Cleared old pending writes`) } public dispose(): void { getLogger().info(`[DiffAnimationHandler] đŸ’Ĩ Disposing DiffAnimationHandler`) - this.fileChangeCache.clear() - this.diffContentMap.clear() - this.fileOriginalContentCache.clear() + this.pendingWrites.clear() this.processedMessages.clear() this.animatingFiles.clear() - this.activeFiles.clear() - this.animationQueue = [] this.diffAnimationController.dispose() + if (this.fileWatcher) { + this.fileWatcher.dispose() + } + // Dispose all event listeners for (const disposable of this.disposables) { disposable.dispose() diff --git a/packages/amazonq/src/lsp/chat/diffController.ts b/packages/amazonq/src/lsp/chat/diffController.ts deleted file mode 100644 index 56c16bda087..00000000000 --- a/packages/amazonq/src/lsp/chat/diffController.ts +++ /dev/null @@ -1,75 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode' -import { diffLines } from 'diff' - -export class RealTimeDiffController { - private decorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0, 255, 0, 0.2)', - isWholeLine: true, - }) - - private deleteDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 0, 0, 0.2)', - isWholeLine: true, - }) - - async applyIncrementalDiff( - editor: vscode.TextEditor, - originalContent: string, - newContent: string, - isPartial: boolean = false - ) { - const diffs = diffLines(originalContent, newContent) - - const addDecorations: vscode.DecorationOptions[] = [] - const deleteDecorations: vscode.DecorationOptions[] = [] - - // Build incremental edits - await editor.edit((editBuilder) => { - let currentLine = 0 - - for (const part of diffs) { - const lines = part.value.split('\n').filter((l) => l !== '') - - if (part.removed) { - // For partial updates, don't delete yet, just mark - if (isPartial) { - const range = new vscode.Range(currentLine, 0, currentLine + lines.length, 0) - deleteDecorations.push({ range }) - } else { - // Final update, actually delete - const range = new vscode.Range(currentLine, 0, currentLine + lines.length, 0) - editBuilder.delete(range) - } - currentLine += lines.length - } else if (part.added) { - // Insert new content with decoration - const position = new vscode.Position(currentLine, 0) - editBuilder.insert(position, part.value) - - // Highlight the added lines - for (let idx = 0; idx < lines.length; idx++) { - addDecorations.push({ - range: new vscode.Range(currentLine + idx, 0, currentLine + idx + 1, 0), - }) - } - } else { - currentLine += lines.length - } - } - }) - - // Apply decorations after edit - editor.setDecorations(this.decorationType, addDecorations) - editor.setDecorations(this.deleteDecorationType, deleteDecorations) - } - - dispose() { - this.decorationType.dispose() - this.deleteDecorationType.dispose() - } -} diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 0edc3b89a68..e31ac900d49 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -73,6 +73,7 @@ import { import { telemetry, TelemetryBase } from 'aws-core-vscode/telemetry' import { isValidResponseError } from './error' import { DiffAnimationHandler } from './diffAnimation/diffAnimationHandler' +import { getLogger } from 'aws-core-vscode/shared' // Create a singleton instance of DiffAnimationHandler let diffAnimationHandler: DiffAnimationHandler | undefined @@ -123,20 +124,26 @@ function getCursorState(selection: readonly vscode.Selection[]) { })) } +// Initialize DiffAnimationHandler on first use +function getDiffAnimationHandler(): DiffAnimationHandler { + if (!diffAnimationHandler) { + diffAnimationHandler = new DiffAnimationHandler() + } + return diffAnimationHandler +} + export function registerMessageListeners( languageClient: LanguageClient, provider: AmazonQChatViewProvider, encryptionKey: Buffer ) { + const chatStreamTokens = new Map() // tab id -> token + // Initialize DiffAnimationHandler - if (!diffAnimationHandler) { - diffAnimationHandler = new DiffAnimationHandler() - languageClient.info('[message.ts] 🚀 Initialized DiffAnimationHandler') - } + const animationHandler = getDiffAnimationHandler() - const chatStreamTokens = new Map() // tab id -> token provider.webview?.onDidReceiveMessage(async (message) => { - languageClient.info(`[VSCode Client] 📨 Received ${JSON.stringify(message)} from chat`) + languageClient.info(`[VSCode Client] Received ${JSON.stringify(message)} from chat`) if ((message.tabType && message.tabType !== 'cwc') || messageDispatcher.isLegacyEvent(message.command)) { // handle the mynah ui -> agent legacy flow @@ -237,24 +244,30 @@ export function registerMessageListeners( const chatDisposable = languageClient.onProgress( chatRequestType, partialResultToken, - (partialResult) => { - // Process partial result with diff animation - void handlePartialResult( - partialResult, - encryptionKey, - provider, - chatParams.tabId, - languageClient, - diffAnimationHandler! - ) - .then((decoded) => { - if (decoded) { - lastPartialResult = decoded - } - }) - .catch((error) => { - languageClient.error(`[message.ts] ❌ Error in partial result handler: ${error}`) - }) + async (partialResult) => { + // Store the latest partial result + if (typeof partialResult === 'string' && encryptionKey) { + const decoded = await decodeRequest(partialResult, encryptionKey) + lastPartialResult = decoded + + // Process partial results for diff animations + try { + await animationHandler.processChatResult(decoded, chatParams.tabId, true) + } catch (error) { + getLogger().error(`Failed to process partial result for animations: ${error}`) + } + } else { + lastPartialResult = partialResult as ChatResult + + // Process partial results for diff animations + try { + await animationHandler.processChatResult(lastPartialResult, chatParams.tabId, true) + } catch (error) { + getLogger().error(`Failed to process partial result for animations: ${error}`) + } + } + + void handlePartialResult(partialResult, encryptionKey, provider, chatParams.tabId) } ) @@ -267,17 +280,7 @@ export function registerMessageListeners( } const chatRequest = await encryptRequest(chatParams, encryptionKey) - - // Add timeout monitoring - const timeoutId = setTimeout(() => { - languageClient.warn( - `[message.ts] âš ī¸ Chat request taking longer than expected for tab ${chatParams.tabId}` - ) - }, 30000) // 30 seconds warning - try { - languageClient.info(`[message.ts] 📤 Sending chat request for tab ${chatParams.tabId}`) - const chatResult = await languageClient.sendRequest( chatRequestType.method, { @@ -286,48 +289,42 @@ export function registerMessageListeners( }, cancellationToken.token ) - - clearTimeout(timeoutId) - languageClient.info(`[message.ts] ✅ Received final chat result for tab ${chatParams.tabId}`) - await handleCompleteResult( chatResult, encryptionKey, provider, chatParams.tabId, - chatDisposable, - languageClient, - diffAnimationHandler! + chatDisposable ) - } catch (e) { - clearTimeout(timeoutId) - const errorMsg = `Error occurred during chat request: ${e}` - languageClient.error(`[message.ts] ❌ ${errorMsg}`) - // Log last partial result for debugging - if (lastPartialResult) { - languageClient.info( - `[message.ts] 📊 Last partial result before error: ${JSON.stringify(lastPartialResult, undefined, 2).substring(0, 500)}...` - ) + // Process final result for animations + const finalResult = + typeof chatResult === 'string' && encryptionKey + ? await decodeRequest(chatResult, encryptionKey) + : (chatResult as ChatResult) + try { + await animationHandler.processChatResult(finalResult, chatParams.tabId, false) + } catch (error) { + getLogger().error(`Failed to process final result for animations: ${error}`) } - + } catch (e) { + const errorMsg = `Error occurred during chat request: ${e}` + languageClient.info(errorMsg) + languageClient.info( + `Last result from langauge server: ${JSON.stringify(lastPartialResult, undefined, 2)}` + ) if (!isValidResponseError(e)) { throw e } - - // Try to handle the error result await handleCompleteResult( e.data, encryptionKey, provider, chatParams.tabId, - chatDisposable, - languageClient, - diffAnimationHandler! + chatDisposable ) } finally { chatStreamTokens.delete(chatParams.tabId) - clearTimeout(timeoutId) } break } @@ -337,13 +334,11 @@ export function registerMessageListeners( quickActionRequestType, quickActionPartialResultToken, (partialResult) => - void handlePartialResult( + handlePartialResult( partialResult, encryptionKey, provider, - message.params.tabId, - languageClient, - diffAnimationHandler! + message.params.tabId ) ) @@ -357,9 +352,7 @@ export function registerMessageListeners( encryptionKey, provider, message.params.tabId, - quickActionDisposable, - languageClient, - diffAnimationHandler! + quickActionDisposable ) break } @@ -483,6 +476,21 @@ export function registerMessageListeners( async (params: ShowDocumentParams): Promise> => { try { const uri = vscode.Uri.parse(params.uri) + + if (params.external) { + // Note: Not using openUrl() because we probably don't want telemetry for these URLs. + // Also it doesn't yet support the required HACK below. + + // HACK: workaround vscode bug: https://github.com/microsoft/vscode/issues/85930 + vscode.env.openExternal(params.uri as any).then(undefined, (e) => { + // TODO: getLogger('?').error('failed vscode.env.openExternal: %O', e) + vscode.env.openExternal(uri).then(undefined, (e) => { + // TODO: getLogger('?').error('failed vscode.env.openExternal: %O', e) + }) + }) + return params + } + const doc = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(doc, { preview: false }) return params @@ -503,68 +511,58 @@ export function registerMessageListeners( }) languageClient.onNotification(openFileDiffNotificationType.method, async (params: OpenFileDiffParams) => { - languageClient.info(`[message.ts] 🎨 Received openFileDiff notification: ${params.originalFileUri}`) - languageClient.info( - `[message.ts] 📏 Original content present: ${!!params.originalFileContent}, length: ${params.originalFileContent?.length || 0}` - ) - languageClient.info( - `[message.ts] 📏 New content present: ${!!params.fileContent}, length: ${params.fileContent?.length || 0}` - ) - - if (diffAnimationHandler) { - await diffAnimationHandler.processFileDiff(params) - } - - const ecc = new EditorContentController() - const uri = params.originalFileUri - const doc = await vscode.workspace.openTextDocument(uri) - const entireDocumentSelection = new vscode.Selection( - new vscode.Position(0, 0), - new vscode.Position(doc.lineCount - 1, doc.lineAt(doc.lineCount - 1).text.length) - ) - const viewDiffMessage: ViewDiffMessage = { - context: { - activeFileContext: { - filePath: params.originalFileUri, - fileText: params.originalFileContent ?? '', - fileLanguage: undefined, - matchPolicy: undefined, - }, - focusAreaContext: { - selectionInsideExtendedCodeBlock: entireDocumentSelection, - codeBlock: '', - extendedCodeBlock: '', - names: undefined, + // Try to use DiffAnimationHandler first + try { + await animationHandler.processFileDiff({ + originalFileUri: params.originalFileUri, + originalFileContent: params.originalFileContent, + fileContent: params.fileContent, + }) + getLogger().info('[VSCode Client] Successfully triggered diff animation') + } catch (error) { + // If animation fails, fall back to the original diff view + getLogger().error(`[VSCode Client] Diff animation failed, falling back to standard diff view: ${error}`) + + const ecc = new EditorContentController() + const uri = params.originalFileUri + const doc = await vscode.workspace.openTextDocument(uri) + const entireDocumentSelection = new vscode.Selection( + new vscode.Position(0, 0), + new vscode.Position(doc.lineCount - 1, doc.lineAt(doc.lineCount - 1).text.length) + ) + const viewDiffMessage: ViewDiffMessage = { + context: { + activeFileContext: { + filePath: params.originalFileUri, + fileText: params.originalFileContent ?? '', + fileLanguage: undefined, + matchPolicy: undefined, + }, + focusAreaContext: { + selectionInsideExtendedCodeBlock: entireDocumentSelection, + codeBlock: '', + extendedCodeBlock: '', + names: undefined, + }, }, - }, - code: params.fileContent ?? '', + code: params.fileContent ?? '', + } + await ecc.viewDiff(viewDiffMessage, amazonQDiffScheme) } - await ecc.viewDiff(viewDiffMessage, amazonQDiffScheme) }) languageClient.onNotification(chatUpdateNotificationType.method, async (params: ChatUpdateParams) => { - languageClient.info(`[message.ts] 🔄 Received chatUpdate notification for tab: ${params.tabId}`) - languageClient.info(`[message.ts] 📊 Update contains ${params.data?.messages?.length || 0} messages`) - - // Log the messages in the update + // Process chat updates for diff animations if (params.data?.messages) { - for (const [index, msg] of params.data.messages.entries()) { - languageClient.info(`[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}`) - if (msg.header?.fileList) { - languageClient.info(`[message.ts] Has fileList: ${msg.header.fileList.filePaths?.join(', ')}`) + for (const message of params.data.messages) { + try { + await animationHandler.processChatResult(message, params.tabId, false) + } catch (error) { + getLogger().error(`Failed to process chat update for animations: ${error}`) } } } - // Process the update through DiffAnimationHandler - if (diffAnimationHandler && params.data?.messages) { - try { - await diffAnimationHandler.processChatUpdate(params) - } catch (error) { - languageClient.error(`[message.ts] ❌ Error processing chat update for animation: ${error}`) - } - } - void provider.webview?.postMessage({ command: chatUpdateNotificationType.method, params: params, @@ -577,15 +575,14 @@ export function registerMessageListeners( params: params, }) }) +} - // Cleanup when provider's webview is disposed - provider.webviewView?.onDidDispose(() => { - if (diffAnimationHandler) { - diffAnimationHandler.dispose() - diffAnimationHandler = undefined - languageClient.info('[message.ts] đŸ’Ĩ Disposed DiffAnimationHandler') - } - }) +// Clean up on extension deactivation +export function dispose() { + if (diffAnimationHandler) { + diffAnimationHandler.dispose() + diffAnimationHandler = undefined + } } function isServerEvent(command: string) { @@ -622,62 +619,13 @@ async function handlePartialResult( partialResult: string | T, encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, - tabId: string, - languageClient: LanguageClient, - diffAnimationHandler: DiffAnimationHandler -): Promise { - languageClient.info(`[message.ts] 📨 Processing partial result for tab ${tabId}`) - + tabId: string +) { const decryptedMessage = typeof partialResult === 'string' && encryptionKey ? await decodeRequest(partialResult, encryptionKey) : (partialResult as T) - // Log the structure of the partial result - languageClient.info(`[message.ts] 📊 Partial result details:`) - languageClient.info(`[message.ts] - messageId: ${decryptedMessage.messageId}`) - languageClient.info(`[message.ts] - has body: ${!!decryptedMessage.body}`) - languageClient.info(`[message.ts] - has header: ${!!decryptedMessage.header}`) - languageClient.info(`[message.ts] - has fileList: ${!!decryptedMessage.header?.fileList}`) - languageClient.info( - `[message.ts] - additionalMessages count: ${decryptedMessage.additionalMessages?.length || 0}` - ) - - // Log additional messages in detail - if (decryptedMessage.additionalMessages && decryptedMessage.additionalMessages.length > 0) { - languageClient.info(`[message.ts] 📋 Additional messages in partial result:`) - for (const [index, msg] of decryptedMessage.additionalMessages.entries()) { - languageClient.info( - `[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}, body length: ${msg.body?.length || 0}` - ) - - // Check for diff content - if (msg.type === 'system-prompt' && msg.messageId) { - if (msg.messageId.endsWith('_original') || msg.messageId.endsWith('_new')) { - languageClient.info(`[message.ts] đŸŽ¯ Found diff content: ${msg.messageId}`) - } - } - - // Check for progress messages (file being processed) - if (msg.type === 'tool' && msg.messageId?.startsWith('progress_')) { - languageClient.info(`[message.ts] đŸŽŦ Found progress message: ${msg.messageId}`) - const fileList = msg.header?.fileList - if (fileList?.filePaths?.[0]) { - languageClient.info(`[message.ts] 📁 File being processed: ${fileList.filePaths[0]}`) - } - } - - // Check for final tool messages - if (msg.type === 'tool' && msg.messageId && !msg.messageId.startsWith('progress_')) { - languageClient.info(`[message.ts] 🔧 Found tool message: ${msg.messageId}`) - if (msg.header?.fileList) { - languageClient.info(`[message.ts] Files: ${msg.header.fileList.filePaths?.join(', ')}`) - } - } - } - } - - // Send to UI first if (decryptedMessage.body !== undefined) { void provider.webview?.postMessage({ command: chatRequestType.method, @@ -686,16 +634,6 @@ async function handlePartialResult( tabId: tabId, }) } - - // Process for diff animation - IMPORTANT: pass true for isPartialResult - languageClient.info('[message.ts] đŸŽŦ Processing partial result for diff animation') - try { - await diffAnimationHandler.processChatResult(decryptedMessage, tabId, true) - } catch (error) { - languageClient.error(`[message.ts] ❌ Error processing partial result for animation: ${error}`) - } - - return decryptedMessage } /** @@ -707,78 +645,16 @@ async function handleCompleteResult( encryptionKey: Buffer | undefined, provider: AmazonQChatViewProvider, tabId: string, - disposable: Disposable, - languageClient: LanguageClient, - diffAnimationHandler: DiffAnimationHandler + disposable: Disposable ) { - languageClient.info(`[message.ts] ✅ Processing complete result for tab ${tabId}`) - const decryptedMessage = typeof result === 'string' && encryptionKey ? await decodeRequest(result, encryptionKey) : (result as T) - - // Log complete result details - languageClient.info(`[message.ts] 📊 Complete result details:`) - languageClient.info(`[message.ts] - Main message ID: ${decryptedMessage.messageId}`) - languageClient.info( - `[message.ts] - Additional messages count: ${decryptedMessage.additionalMessages?.length || 0}` - ) - - // Log additional messages in detail - if (decryptedMessage.additionalMessages && decryptedMessage.additionalMessages.length > 0) { - languageClient.info(`[message.ts] 📋 Additional messages in complete result:`) - for (const [index, msg] of decryptedMessage.additionalMessages.entries()) { - languageClient.info( - `[message.ts] [${index}] type: ${msg.type}, messageId: ${msg.messageId}, body length: ${msg.body?.length || 0}` - ) - - // Check for diff content - if (msg.type === 'system-prompt' && msg.messageId) { - if (msg.messageId.endsWith('_original')) { - const toolUseId = msg.messageId.replace('_original', '') - languageClient.info( - `[message.ts] đŸŽ¯ Found original diff content for toolUse: ${toolUseId}, content length: ${msg.body?.length || 0}` - ) - // Log first 100 chars of content - if (msg.body) { - languageClient.info(`[message.ts] Content preview: ${msg.body.substring(0, 100)}...`) - } - } else if (msg.messageId.endsWith('_new')) { - const toolUseId = msg.messageId.replace('_new', '') - languageClient.info( - `[message.ts] đŸŽ¯ Found new diff content for toolUse: ${toolUseId}, content length: ${msg.body?.length || 0}` - ) - // Log first 100 chars of content - if (msg.body) { - languageClient.info(`[message.ts] Content preview: ${msg.body.substring(0, 100)}...`) - } - } - } - - // Check for final tool messages - if (msg.type === 'tool' && msg.messageId && !msg.messageId.startsWith('progress_')) { - languageClient.info(`[message.ts] 🔧 Found tool completion message: ${msg.messageId}`) - if (msg.header?.fileList) { - languageClient.info(`[message.ts] Files affected: ${msg.header.fileList.filePaths?.join(', ')}`) - } - } - } - } - - // Send to UI void provider.webview?.postMessage({ command: chatRequestType.method, params: decryptedMessage, tabId: tabId, }) - // Process for diff animation - IMPORTANT: pass false for isPartialResult - languageClient.info('[message.ts] đŸŽŦ Processing complete result for diff animation') - try { - await diffAnimationHandler.processChatResult(decryptedMessage, tabId, false) - } catch (error) { - languageClient.error(`[message.ts] ❌ Error processing complete result for animation: ${error}`) - } - // only add the reference log once the request is complete, otherwise we will get duplicate log items for (const ref of decryptedMessage.codeReference ?? []) { ReferenceLogViewProvider.instance.addReferenceLog(referenceLogText(ref)) From 43ea2aa8b488b1add8580f6427cf449c61433c39 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 9 Jun 2025 01:15:25 -0700 Subject: [PATCH 07/32] Create a temporary file for showing diff animation --- .../diffAnimation/diffAnimationHandler.ts | 636 +++++++++++++++++- 1 file changed, 605 insertions(+), 31 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index 752a0155e35..23c2d6b56a8 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -3,8 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * DiffAnimationHandler - Temporary File Animation Approach + * + * Uses temporary files to show diff animations, with one temp file per source file: + * 1. When file change detected, create or reuse a temporary file + * 2. Show animation in the temporary file (red deletions → green additions) + * 3. Update the actual file with final content + * 4. Keep temp file open for reuse on subsequent changes + * + * Benefits: + * - Deletion animations (red lines) are always visible + * - One temp file per source file - reused for multiple animations + * - Clear separation between animation and actual file + * - No race conditions or timing issues + */ + import * as vscode from 'vscode' import * as path from 'path' +import * as os from 'os' import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' import { getLogger } from 'aws-core-vscode/shared' import { DiffAnimationController } from './diffAnimationController' @@ -17,6 +34,30 @@ interface PendingFileWrite { } export class DiffAnimationHandler implements vscode.Disposable { + /** + * BEHAVIOR SUMMARY: + * + * 1. ONE TEMP FILE PER SOURCE FILE + * - Each source file gets exactly ONE temporary file + * - The temp file is reused for all subsequent changes + * - Example: "index.js" → "[DIFF] index.js" (always the same temp file) + * + * 2. TEMP FILES AUTOMATICALLY OPEN + * - When a file is about to be modified, its temp file opens automatically + * - Temp files appear in the second column (side-by-side view) + * - Files stay open for future animations + * + * 3. ANIMATION FLOW + * - Detect change in source file + * - Find or create temp file for that source + * - Replace temp file content with original + * - Run animation (red deletions → green additions) + * - Return focus to source file + * - Keep temp file open for next time + * + * This ensures deletion animations always show properly! + */ + private diffAnimationController: DiffAnimationController private disposables: vscode.Disposable[] = [] @@ -28,6 +69,10 @@ export class DiffAnimationHandler implements vscode.Disposable { private processedMessages = new Set() // File system watcher private fileWatcher: vscode.FileSystemWatcher | undefined + // Track temporary files for cleanup - maps original file path to temp file path + private tempFileMapping = new Map() + // Track open temp file editors - maps temp file path to editor + private tempFileEditors = new Map() constructor() { getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler`) @@ -38,11 +83,19 @@ export class DiffAnimationHandler implements vscode.Disposable { // Watch for file changes this.fileWatcher.onDidChange(async (uri) => { + // Skip temporary files + if (this.isTempFile(uri.fsPath)) { + return + } await this.handleFileChange(uri) }) // Watch for file creation this.fileWatcher.onDidCreate(async (uri) => { + // Skip temporary files + if (this.isTempFile(uri.fsPath)) { + return + } await this.handleFileChange(uri) }) @@ -54,12 +107,140 @@ export class DiffAnimationHandler implements vscode.Disposable { return } + // Skip temporary files + if (this.isTempFile(event.document.uri.fsPath)) { + return + } + + // Skip if we're currently animating this file + if (this.animatingFiles.has(event.document.uri.fsPath)) { + return + } + // Check if this is an external change (not from user typing) if (event.reason === undefined) { await this.handleFileChange(event.document.uri) } }) this.disposables.push(changeTextDocumentDisposable) + + // Listen for editor close events to clean up temp file references + const onDidCloseTextDocument = vscode.workspace.onDidCloseTextDocument((document) => { + const filePath = document.uri.fsPath + if (this.isTempFile(filePath)) { + // Remove from editor tracking + this.tempFileEditors.delete(filePath) + getLogger().info(`[DiffAnimationHandler] 📄 Temp file editor closed: ${filePath}`) + } + }) + this.disposables.push(onDidCloseTextDocument) + } + + /** + * Check if a file path is a temporary file + */ + private isTempFile(filePath: string): boolean { + // Check if this path is in our temp file mappings + for (const tempPath of this.tempFileMapping.values()) { + if (filePath === tempPath) { + return true + } + } + return false + } + + /** + * Focus on the temp file for a specific source file (if it exists) + */ + public async focusTempFile(sourceFilePath: string): Promise { + const tempFilePath = this.tempFileMapping.get(sourceFilePath) + if (!tempFilePath) { + return false + } + + const editor = this.tempFileEditors.get(tempFilePath) + if (editor && !editor.document.isClosed) { + await vscode.window.showTextDocument(editor.document, { + preview: false, + preserveFocus: false, + }) + getLogger().info(`[DiffAnimationHandler] đŸ‘ī¸ Focused on temp file for: ${sourceFilePath}`) + return true + } + + return false + } + + /** + * Get information about active temp files (for debugging) + */ + public getTempFileInfo(): { sourceFile: string; tempFile: string; isOpen: boolean }[] { + const info: { sourceFile: string; tempFile: string; isOpen: boolean }[] = [] + + for (const [sourceFile, tempFile] of this.tempFileMapping) { + const editor = this.tempFileEditors.get(tempFile) + info.push({ + sourceFile, + tempFile, + isOpen: editor ? !editor.document.isClosed : false, + }) + } + + return info + } + + /** + * Close temp file for a specific source file + */ + public async closeTempFileForSource(sourceFilePath: string): Promise { + const tempFilePath = this.tempFileMapping.get(sourceFilePath) + if (!tempFilePath) { + return + } + + const editor = this.tempFileEditors.get(tempFilePath) + if (editor && !editor.document.isClosed) { + // We can't programmatically close the editor, but we can clean up our references + this.tempFileEditors.delete(tempFilePath) + getLogger().info(`[DiffAnimationHandler] 🧹 Cleaned up temp file references for: ${sourceFilePath}`) + } + + // Delete the temp file + try { + await vscode.workspace.fs.delete(vscode.Uri.file(tempFilePath)) + this.tempFileMapping.delete(sourceFilePath) + getLogger().info(`[DiffAnimationHandler] đŸ—‘ī¸ Deleted temp file: ${tempFilePath}`) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to delete temp file: ${error}`) + } + } + + /** + * Test method to manually trigger animation (for debugging) + */ + public async testAnimation(): Promise { + const originalContent = `function hello() { + console.log("Hello World"); + return true; +}` + + const newContent = `function hello(name) { + console.log(\`Hello \${name}!\`); + console.log("Welcome to the app"); + return { success: true, name: name }; +}` + + const testFilePath = path.join( + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.tmpdir(), + 'test_animation.js' + ) + getLogger().info(`[DiffAnimationHandler] đŸ§Ē Running test animation for: ${testFilePath}`) + + // First simulate the preparation phase (which opens the temp file) + await this.openOrCreateTempFile(testFilePath, originalContent) + + // Then run the animation + await this.animateFileChangeWithTemp(testFilePath, originalContent, newContent, 'test') } /** @@ -98,6 +279,14 @@ export class DiffAnimationHandler implements vscode.Disposable { return } + // Deduplicate messages + const messageKey = `${message.messageId}_${message.type}` + if (this.processedMessages.has(messageKey)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Already processed message: ${messageKey}`) + return + } + this.processedMessages.add(messageKey) + // Check for fsWrite tool preparation (when tool is about to execute) if (message.type === 'tool' && message.messageId.startsWith('progress_')) { await this.processFsWritePreparation(message, tabId) @@ -130,6 +319,12 @@ export class DiffAnimationHandler implements vscode.Disposable { getLogger().info(`[DiffAnimationHandler] đŸŽŦ Preparing for fsWrite: ${filePath} (toolUse: ${toolUseId})`) + // Check if we already have a pending write for this file + if (this.pendingWrites.has(filePath)) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Already have pending write for ${filePath}, skipping`) + return + } + // Capture current content IMMEDIATELY before the write happens let originalContent = '' let fileExists = false @@ -166,25 +361,141 @@ export class DiffAnimationHandler implements vscode.Disposable { await vscode.workspace.fs.writeFile(uri, Buffer.from('')) } - // Open the document + // Open the document (but keep it in background) const document = await vscode.workspace.openTextDocument(uri) await vscode.window.showTextDocument(document, { preview: false, - preserveFocus: false, + preserveFocus: true, // Keep focus on current editor + viewColumn: vscode.ViewColumn.One, // Open in first column }) + // IMPORTANT: Automatically open the corresponding temp file if it exists + // This ensures the user can see the animation without manually opening the temp file + await this.openOrCreateTempFile(filePath, originalContent) + getLogger().info(`[DiffAnimationHandler] ✅ File opened and ready: ${filePath}`) } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Failed to prepare file: ${error}`) + // Clean up on error + this.pendingWrites.delete(filePath) } } + /** + * Open or create the temp file for a source file + */ + private async openOrCreateTempFile(sourceFilePath: string, initialContent: string): Promise { + const tempFilePath = this.getOrCreateTempFilePath(sourceFilePath) + + // Check if we already have an editor open for this temp file + let tempFileEditor = this.tempFileEditors.get(tempFilePath) + + if (tempFileEditor && tempFileEditor.document && !tempFileEditor.document.isClosed) { + // Temp file is already open, just ensure it's visible + getLogger().info(`[DiffAnimationHandler] đŸ‘ī¸ Temp file already open, making it visible`) + await vscode.window.showTextDocument(tempFileEditor.document, { + preview: false, + preserveFocus: true, + viewColumn: vscode.ViewColumn.Two, + }) + } else { + // Need to create/open the temp file + getLogger().info(`[DiffAnimationHandler] 📄 Opening temp file for: ${sourceFilePath}`) + + const tempUri = vscode.Uri.file(tempFilePath) + + try { + // Check if temp file exists + await vscode.workspace.fs.stat(tempUri) + } catch { + // File doesn't exist, create it with initial content + await vscode.workspace.fs.writeFile(tempUri, Buffer.from(initialContent, 'utf8')) + } + + // Ensure we have a two-column layout + await vscode.commands.executeCommand('workbench.action.editorLayoutTwoColumns') + + // Open temp file in editor + const tempDoc = await vscode.workspace.openTextDocument(tempUri) + tempFileEditor = await vscode.window.showTextDocument(tempDoc, { + preview: false, + preserveFocus: true, // Don't steal focus + viewColumn: vscode.ViewColumn.Two, // Show in second column + }) + + // Add a header comment to indicate this is a diff animation file + const header = `// đŸŽŦ DIFF ANIMATION for: ${path.basename(sourceFilePath)}\n// This file shows animations of changes (Red = Deleted, Green = Added)\n// ${'='.repeat(60)}\n\n` + if (!tempDoc.getText().startsWith(header)) { + await tempFileEditor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), header) + }) + await tempDoc.save() + } + + // Store the editor reference + this.tempFileEditors.set(tempFilePath, tempFileEditor) + + // Set the language mode to match the original file + const ext = path.extname(sourceFilePath).substring(1).toLowerCase() + const languageMap: { [key: string]: string } = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + rb: 'ruby', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + cs: 'csharp', + php: 'php', + swift: 'swift', + kt: 'kotlin', + md: 'markdown', + json: 'json', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + html: 'html', + css: 'css', + scss: 'scss', + less: 'less', + sql: 'sql', + sh: 'shellscript', + bash: 'shellscript', + ps1: 'powershell', + r: 'r', + dart: 'dart', + vue: 'vue', + lua: 'lua', + pl: 'perl', + } + const languageId = languageMap[ext] || ext || 'plaintext' + + try { + await vscode.languages.setTextDocumentLanguage(tempDoc, languageId) + getLogger().info(`[DiffAnimationHandler] 🎨 Set language mode to: ${languageId}`) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to set language mode: ${error}`) + } + } + + getLogger().info(`[DiffAnimationHandler] ✅ Temp file is ready and visible`) + } + /** * Handle file changes - this is where we detect the actual write */ private async handleFileChange(uri: vscode.Uri): Promise { const filePath = uri.fsPath + // Skip if we're already animating this file + if (this.animatingFiles.has(filePath)) { + return + } + // Check if we have a pending write for this file const pendingWrite = this.pendingWrites.get(filePath) if (!pendingWrite) { @@ -201,8 +512,8 @@ export class DiffAnimationHandler implements vscode.Disposable { try { // Read the new content - const document = await vscode.workspace.openTextDocument(uri) - const newContent = document.getText() + const newContentBuffer = await vscode.workspace.fs.readFile(uri) + const newContent = Buffer.from(newContentBuffer).toString('utf8') // Check if content actually changed if (pendingWrite.originalContent !== newContent) { @@ -211,8 +522,13 @@ export class DiffAnimationHandler implements vscode.Disposable { `original: ${pendingWrite.originalContent.length} chars, new: ${newContent.length} chars` ) - // Start the animation - await this.animateFileChange(filePath, pendingWrite.originalContent, newContent, pendingWrite.toolUseId) + // Start the animation using temporary file + await this.animateFileChangeWithTemp( + filePath, + pendingWrite.originalContent, + newContent, + pendingWrite.toolUseId + ) } else { getLogger().info(`[DiffAnimationHandler] â„šī¸ No content change for: ${filePath}`) } @@ -222,9 +538,45 @@ export class DiffAnimationHandler implements vscode.Disposable { } /** - * Animate file changes + * Get or create a temporary file path for the given original file + */ + private getOrCreateTempFilePath(originalPath: string): string { + // Check if we already have a temp file for this original file + const existingTempPath = this.tempFileMapping.get(originalPath) + if (existingTempPath) { + getLogger().info(`[DiffAnimationHandler] 🔄 Reusing existing temp file: ${existingTempPath}`) + return existingTempPath + } + + // Create new temp file path + const ext = path.extname(originalPath) + const basename = path.basename(originalPath, ext) + // Use a consistent name for the temp file (no timestamp) so it's easier to identify + const tempName = `[DIFF] ${basename}${ext}` + const tempDir = path.join(os.tmpdir(), 'vscode-diff-animations') + + // Ensure temp directory exists + try { + if (!require('fs').existsSync(tempDir)) { + require('fs').mkdirSync(tempDir, { recursive: true }) + } + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] Failed to create temp dir: ${error}`) + } + + const tempPath = path.join(tempDir, tempName) + + // Store the mapping + this.tempFileMapping.set(originalPath, tempPath) + getLogger().info(`[DiffAnimationHandler] 📄 Created new temp file mapping: ${originalPath} → ${tempPath}`) + + return tempPath + } + + /** + * Animate file changes using a temporary file */ - private async animateFileChange( + private async animateFileChangeWithTemp( filePath: string, originalContent: string, newContent: string, @@ -236,27 +588,209 @@ export class DiffAnimationHandler implements vscode.Disposable { } this.animatingFiles.add(filePath) + const animationId = `${path.basename(filePath)}_${Date.now()}` + + // Get or create temporary file path + const tempFilePath = this.getOrCreateTempFilePath(filePath) + + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting animation ${animationId}`) + getLogger().info(`[DiffAnimationHandler] 📄 Using temporary file: ${tempFilePath}`) + + let tempFileEditor: vscode.TextEditor | undefined try { - getLogger().info(`[DiffAnimationHandler] 🔄 Animating file change: ${filePath}`) + // Check if we already have an editor open for this temp file + tempFileEditor = this.tempFileEditors.get(tempFilePath) + + if (tempFileEditor && tempFileEditor.document && !tempFileEditor.document.isClosed) { + // Reuse existing editor + getLogger().info(`[DiffAnimationHandler] â™ģī¸ Reusing existing temp file editor`) + + // Make sure it's visible and focused for the animation + tempFileEditor = await vscode.window.showTextDocument(tempFileEditor.document, { + preview: false, + preserveFocus: false, // Take focus for animation + viewColumn: tempFileEditor.viewColumn || vscode.ViewColumn.Two, + }) + + // Replace content with original content for this animation + await tempFileEditor.edit((editBuilder) => { + const fullRange = new vscode.Range( + tempFileEditor!.document.positionAt(0), + tempFileEditor!.document.positionAt(tempFileEditor!.document.getText().length) + ) + editBuilder.replace(fullRange, originalContent) + }) + + await tempFileEditor.document.save() + } else { + // Create new temp file or open existing one + const tempUri = vscode.Uri.file(tempFilePath) - // Open the file - try { - const uri = vscode.Uri.file(filePath) - const document = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(document, { preview: false }) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${error}`) + // Write original content to temp file + getLogger().info( + `[DiffAnimationHandler] 📝 Writing original content to temp file (${originalContent.length} chars)` + ) + await vscode.workspace.fs.writeFile(tempUri, Buffer.from(originalContent, 'utf8')) + + // Open temp file in editor + let tempDoc = await vscode.workspace.openTextDocument(tempUri) + + // Ensure the temp document has the correct content + if (tempDoc.getText() !== originalContent) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Temp file content mismatch, rewriting...`) + await vscode.workspace.fs.writeFile(tempUri, Buffer.from(originalContent, 'utf8')) + tempDoc = await vscode.workspace.openTextDocument(tempUri) + } + + // Show the temp file in a new editor + tempFileEditor = await vscode.window.showTextDocument(tempDoc, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Two, // Show in second column + }) + + // Store the editor reference + this.tempFileEditors.set(tempFilePath, tempFileEditor) + + // Set the language mode to match the original file for proper syntax highlighting + const ext = path.extname(filePath).substring(1).toLowerCase() + const languageMap: { [key: string]: string } = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + rb: 'ruby', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + cs: 'csharp', + php: 'php', + swift: 'swift', + kt: 'kotlin', + md: 'markdown', + json: 'json', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + html: 'html', + css: 'css', + scss: 'scss', + less: 'less', + sql: 'sql', + sh: 'shellscript', + bash: 'shellscript', + ps1: 'powershell', + r: 'r', + dart: 'dart', + vue: 'vue', + lua: 'lua', + pl: 'perl', + } + const languageId = languageMap[ext] || ext || 'plaintext' + + try { + await vscode.languages.setTextDocumentLanguage(tempDoc, languageId) + getLogger().info(`[DiffAnimationHandler] 🎨 Set language mode to: ${languageId}`) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to set language mode: ${error}`) + } } - // Start the animation - await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) + // Wait for editor to be ready + await new Promise((resolve) => setTimeout(resolve, 300)) + + // Verify the editor is showing our temp file + if (vscode.window.activeTextEditor?.document.uri.fsPath !== tempFilePath) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Active editor is not showing temp file, refocusing...`) + tempFileEditor = await vscode.window.showTextDocument(tempFileEditor.document, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Active, + }) + await new Promise((resolve) => setTimeout(resolve, 200)) + } + + // Double-check the document content before animation + const currentContent = tempFileEditor.document.getText() + if (currentContent !== originalContent) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Document content changed, restoring original content`) + await tempFileEditor.edit((editBuilder) => { + const fullRange = new vscode.Range( + tempFileEditor!.document.positionAt(0), + tempFileEditor!.document.positionAt(currentContent.length) + ) + editBuilder.replace(fullRange, originalContent) + }) + await tempFileEditor.document.save() + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + getLogger().info(`[DiffAnimationHandler] 🎨 Starting diff animation on temp file`) + getLogger().info( + `[DiffAnimationHandler] 📊 Animation details: from ${originalContent.length} chars to ${newContent.length} chars` + ) + + // Show a status message + vscode.window.setStatusBarMessage(`đŸŽŦ Animating changes for ${path.basename(filePath)}...`, 5000) - getLogger().info(`[DiffAnimationHandler] ✅ Animation completed for: ${filePath}`) + // Ensure the temp file editor is still active + if (vscode.window.activeTextEditor !== tempFileEditor) { + await vscode.window.showTextDocument(tempFileEditor.document, { + preview: false, + preserveFocus: false, + }) + } + + // Run animation on temp file + try { + await this.diffAnimationController.startDiffAnimation(tempFilePath, originalContent, newContent) + getLogger().info(`[DiffAnimationHandler] ✅ Animation completed successfully`) + } catch (animError) { + getLogger().error(`[DiffAnimationHandler] ❌ Animation failed: ${animError}`) + // Try alternative approach: direct file write + getLogger().info(`[DiffAnimationHandler] 🔄 Attempting fallback animation approach`) + await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(newContent, 'utf8')) + throw animError + } + + // IMPORTANT: We keep the temp file open after animation! + // This allows us to reuse it for subsequent changes to the same file. + // The temp file will show all animations for a specific source file. + // Benefits: + // - One temp file per source file (not multiple) + // - User can see the history of changes + // - Better performance (no need to create new files) + // - Clear visual separation from actual file + // - Automatically opens when file is being modified + + // Keep temp file open after animation (don't close it) + // The user can close it manually or it will be reused for next animation + getLogger().info(`[DiffAnimationHandler] 📌 Keeping temp file open for potential reuse`) + + // Show completion message + vscode.window.setStatusBarMessage(`✅ Animation completed for ${path.basename(filePath)}`, 3000) + + // Focus back on the original file + const originalUri = vscode.Uri.file(filePath) + try { + const originalDoc = await vscode.workspace.openTextDocument(originalUri) + await vscode.window.showTextDocument(originalDoc, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.One, + }) + } catch (error) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not focus original file: ${error}`) + } } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate: ${error}`) + getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate ${animationId}: ${error}`) } finally { this.animatingFiles.delete(filePath) + getLogger().info(`[DiffAnimationHandler] 🏁 Animation ${animationId} completed`) } } @@ -283,15 +817,8 @@ export class DiffAnimationHandler implements vscode.Disposable { if (originalContent !== newContent) { getLogger().info(`[DiffAnimationHandler] ✨ Content differs, starting diff animation`) - // Open the file first - try { - const uri = vscode.Uri.file(filePath) - await vscode.window.showTextDocument(uri, { preview: false }) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not open file: ${error}`) - } - - await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent) + // Use temp file approach for this too + await this.animateFileChangeWithTemp(filePath, originalContent, newContent, 'manual_diff') } else { getLogger().warn(`[DiffAnimationHandler] âš ī¸ Original and new content are identical`) } @@ -405,20 +932,67 @@ export class DiffAnimationHandler implements vscode.Disposable { const now = Date.now() const timeout = 5 * 60 * 1000 // 5 minutes + let cleanedWrites = 0 for (const [filePath, write] of this.pendingWrites) { if (now - write.timestamp > timeout) { this.pendingWrites.delete(filePath) + cleanedWrites++ } } - getLogger().info(`[DiffAnimationHandler] 🧹 Cleared old pending writes`) + // Clear processed messages to prevent memory leak + if (this.processedMessages.size > 1000) { + const oldSize = this.processedMessages.size + this.processedMessages.clear() + getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${oldSize} processed messages`) + } + + // Clean up closed temp file editors + for (const [tempPath, editor] of this.tempFileEditors) { + if (!editor || editor.document.isClosed) { + this.tempFileEditors.delete(tempPath) + getLogger().info(`[DiffAnimationHandler] 🧹 Removed closed temp file editor: ${tempPath}`) + } + } + + if (cleanedWrites > 0) { + getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${cleanedWrites} old pending writes`) + } } - public dispose(): void { + public async dispose(): Promise { getLogger().info(`[DiffAnimationHandler] đŸ’Ĩ Disposing DiffAnimationHandler`) + + // Close all temp file editors + for (const [tempPath, editor] of this.tempFileEditors) { + try { + if (editor && !editor.document.isClosed) { + getLogger().info(`[DiffAnimationHandler] 📄 Closing temp file editor: ${tempPath}`) + // Note: We can't programmatically close editors, but we can clean up our references + } + } catch (error) { + // Ignore errors during cleanup + } + } + + // Clean up any remaining temp files + for (const tempPath of this.tempFileMapping.values()) { + try { + await vscode.workspace.fs.delete(vscode.Uri.file(tempPath)) + getLogger().info(`[DiffAnimationHandler] đŸ—‘ī¸ Deleted temp file: ${tempPath}`) + } catch (error) { + // Ignore errors during cleanup + } + } + + // Clear all tracking sets and maps this.pendingWrites.clear() this.processedMessages.clear() this.animatingFiles.clear() + this.tempFileMapping.clear() + this.tempFileEditors.clear() + + // Dispose the diff animation controller this.diffAnimationController.dispose() if (this.fileWatcher) { From 45920dcc47cc715f9c98515acc7c987621f23280 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 9 Jun 2025 01:31:45 -0700 Subject: [PATCH 08/32] Fix the remove lines not shown problems --- .../diffAnimation/diffAnimationController.ts | 78 ++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index c9f9b3143aa..8345aba4b63 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -254,6 +254,7 @@ export class DiffAnimationController { // Split new content into lines for accurate mapping let currentLineInNew = 0 + let currentLineInOld = 0 getLogger().info(`[DiffAnimationController] 📊 Document has ${document.lineCount} lines`) @@ -287,15 +288,29 @@ export class DiffAnimationController { } } } else if (change.removed) { - // For deletions, we track them but can't show in new content + // For deletions, create decorations at the current line position for (let j = 0; j < lineCount; j++) { - getLogger().info( - `[DiffAnimationController] ➖ Removed line: "${changeLines[j]?.substring(0, 50) || ''}..."` - ) + try { + // Use the current line position for the deletion decoration + const line = document.lineAt(currentLineInNew) + deletions.push({ + range: line.range, + hoverMessage: `Removed: ${changeLines[j]}`, + }) + getLogger().info( + `[DiffAnimationController] ➖ Marked deletion at line ${currentLineInNew}: "${changeLines[j]?.substring(0, 50) || ''}..."` + ) + } catch (error) { + getLogger().warn( + `[DiffAnimationController] âš ī¸ Could not mark deletion at line ${currentLineInNew}: ${error}` + ) + } } + currentLineInOld += lineCount } else { - // Unchanged lines + // Unchanged lines - increment both counters currentLineInNew += lineCount + currentLineInOld += lineCount } } @@ -313,24 +328,32 @@ export class DiffAnimationController { decorations: DiffAnimation['decorations'], filePath: string ): Promise { - const { additions } = decorations + const { additions, deletions } = decorations const timeouts: NodeJS.Timeout[] = [] - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting animation with ${additions.length} additions`) + getLogger().info( + `[DiffAnimationController] đŸŽŦ Starting animation with ${additions.length} additions, ${deletions.length} deletions` + ) // Clear previous decorations editor.setDecorations(this.additionDecorationType, []) editor.setDecorations(this.deletionDecorationType, []) editor.setDecorations(this.currentLineDecorationType, []) + // Show all deletions immediately with red background + if (deletions.length > 0) { + editor.setDecorations(this.deletionDecorationType, deletions) + getLogger().info(`[DiffAnimationController] 🔴 Applied ${deletions.length} deletion decorations`) + } + // Group additions by proximity for smoother scrolling const additionGroups = this.groupAdditionsByProximity(additions) let currentGroupIndex = 0 let additionsShown = 0 - // If no additions, just show a completion message - if (additions.length === 0) { - getLogger().info(`[DiffAnimationController] â„šī¸ No additions to animate`) + // If no changes to animate, show completion message + if (additions.length === 0 && deletions.length === 0) { + getLogger().info(`[DiffAnimationController] â„šī¸ No changes to animate`) return } @@ -357,6 +380,9 @@ export class DiffAnimationController { editor.setDecorations(this.currentLineDecorationType, []) }, this.animationSpeed * 0.8) + // Keep deletions visible throughout the animation + editor.setDecorations(this.deletionDecorationType, deletions) + // Smart scrolling logic const currentGroup = additionGroups[currentGroupIndex] const isLastInGroup = currentGroup && i === currentGroup[currentGroup.length - 1].index @@ -405,7 +431,8 @@ export class DiffAnimationController { // Gradually fade out decorations editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.fadeDecorationType, additions) + editor.setDecorations(this.deletionDecorationType, []) + editor.setDecorations(this.fadeDecorationType, [...additions, ...deletions]) // Remove all decorations after fade setTimeout(() => { @@ -431,26 +458,38 @@ export class DiffAnimationController { decorations: DiffAnimation['decorations'], filePath: string ): Promise { - const { additions } = decorations + const { additions, deletions } = decorations - if (additions.length === 0) { + if (additions.length === 0 && deletions.length === 0) { getLogger().info(`[DiffAnimationController] â„šī¸ No incremental changes to animate`) return } - // For incremental updates, show all changes immediately with a flash effect - editor.setDecorations(this.currentLineDecorationType, additions) + // Clear previous decorations + editor.setDecorations(this.additionDecorationType, []) + editor.setDecorations(this.deletionDecorationType, []) + editor.setDecorations(this.currentLineDecorationType, []) + + // Show all changes immediately with a flash effect + const allChanges = [...additions, ...deletions] + editor.setDecorations(this.currentLineDecorationType, allChanges) // Flash effect setTimeout(() => { editor.setDecorations(this.currentLineDecorationType, []) - editor.setDecorations(this.additionDecorationType, additions) + if (additions.length > 0) { + editor.setDecorations(this.additionDecorationType, additions) + } + if (deletions.length > 0) { + editor.setDecorations(this.deletionDecorationType, deletions) + } }, 200) // Fade after a shorter delay for incremental updates setTimeout(() => { editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.fadeDecorationType, additions) + editor.setDecorations(this.deletionDecorationType, []) + editor.setDecorations(this.fadeDecorationType, allChanges) setTimeout(() => { editor.setDecorations(this.fadeDecorationType, []) @@ -458,8 +497,9 @@ export class DiffAnimationController { }, 1000) // Scroll to first change - if (additions.length > 0 && this.shouldScrollToLine(editor, additions[0].range)) { - editor.revealRange(additions[0].range, vscode.TextEditorRevealType.InCenterIfOutsideViewport) + const firstChange = allChanges[0] + if (firstChange && this.shouldScrollToLine(editor, firstChange.range)) { + editor.revealRange(firstChange.range, vscode.TextEditorRevealType.InCenterIfOutsideViewport) } } From b279a7b4c359c94f11f533bc0545f59c1df030eb Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 9 Jun 2025 16:09:01 -0700 Subject: [PATCH 09/32] Change the streamline type to the exact Cline one --- .../diffAnimation/diffAnimationController.ts | 1030 ++++++++++------- 1 file changed, 581 insertions(+), 449 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index 8345aba4b63..f27d5faf190 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -5,128 +5,213 @@ import * as vscode from 'vscode' import { getLogger } from 'aws-core-vscode/shared' -import { diffLines, Change } from 'diff' +import { diffLines } from 'diff' + +// Decoration controller to manage decoration states +class DecorationController { + private decorationType: 'fadedOverlay' | 'activeLine' | 'addition' | 'deletion' | 'deletionMarker' + private editor: vscode.TextEditor + private ranges: vscode.Range[] = [] + + constructor( + decorationType: 'fadedOverlay' | 'activeLine' | 'addition' | 'deletion' | 'deletionMarker', + editor: vscode.TextEditor + ) { + this.decorationType = decorationType + this.editor = editor + } + + getDecoration() { + switch (this.decorationType) { + case 'fadedOverlay': + return fadedOverlayDecorationType + case 'activeLine': + return activeLineDecorationType + case 'addition': + return githubAdditionDecorationType + case 'deletion': + return githubDeletionDecorationType + case 'deletionMarker': + return deletionMarkerDecorationType + } + } + + addLines(startIndex: number, numLines: number) { + // Guard against invalid inputs + if (startIndex < 0 || numLines <= 0) { + return + } + + const lastRange = this.ranges[this.ranges.length - 1] + if (lastRange && lastRange.end.line === startIndex - 1) { + this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines)) + } else { + const endLine = startIndex + numLines - 1 + this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER)) + } + + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + clear() { + this.ranges = [] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + updateOverlayAfterLine(line: number, totalLines: number) { + // Remove any existing ranges that start at or after the current line + this.ranges = this.ranges.filter((range) => range.end.line < line) + + // Add a new range for all lines after the current line + if (line < totalLines - 1) { + this.ranges.push( + new vscode.Range( + new vscode.Position(line + 1, 0), + new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER) + ) + ) + } + + // Apply the updated decorations + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + setActiveLine(line: number) { + this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + setRanges(ranges: vscode.Range[]) { + this.ranges = ranges + this.editor.setDecorations(this.getDecoration(), this.ranges) + } +} + +// Decoration types matching Cline's style +const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 255, 0, 0.1)', + opacity: '0.4', + isWholeLine: true, +}) + +const activeLineDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 255, 0, 0.3)', + opacity: '1', + isWholeLine: true, + border: '1px solid rgba(255, 255, 0, 0.5)', +}) + +// GitHub-style diff decorations +const githubAdditionDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(46, 160, 67, 0.15)', + isWholeLine: true, + before: { + contentText: '+', + color: 'rgb(46, 160, 67)', + fontWeight: 'bold', + width: '20px', + margin: '0 10px 0 0', + }, + overviewRulerColor: 'rgba(46, 160, 67, 0.8)', + overviewRulerLane: vscode.OverviewRulerLane.Right, +}) + +const githubDeletionDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(248, 81, 73, 0.15)', + isWholeLine: true, + textDecoration: 'line-through', + opacity: '0.7', + before: { + contentText: '-', + color: 'rgb(248, 81, 73)', + fontWeight: 'bold', + width: '20px', + margin: '0 10px 0 0', + }, + overviewRulerColor: 'rgba(248, 81, 73, 0.8)', + overviewRulerLane: vscode.OverviewRulerLane.Right, +}) + +// Decoration for showing deletion markers +const deletionMarkerDecorationType = vscode.window.createTextEditorDecorationType({ + after: { + contentText: ' ← line(s) removed', + color: 'rgba(248, 81, 73, 0.7)', + fontStyle: 'italic', + margin: '0 0 0 20px', + }, + overviewRulerColor: 'rgba(248, 81, 73, 0.8)', + overviewRulerLane: vscode.OverviewRulerLane.Left, +}) export interface DiffAnimation { uri: vscode.Uri originalContent: string newContent: string - decorations: { - additions: vscode.DecorationOptions[] - deletions: vscode.DecorationOptions[] - } + isShowingStaticDiff?: boolean + animationCancelled?: boolean + diffViewContent?: string // Store the diff view content } export class DiffAnimationController { - // Make decorations more visible with stronger colors and animations - private readonly additionDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0, 255, 0, 0.3)', - isWholeLine: true, - border: '2px solid rgba(0, 255, 0, 0.8)', - borderRadius: '3px', - after: { - contentText: ' ✨ Added by Amazon Q', - color: 'rgba(0, 255, 0, 1)', - fontWeight: 'bold', - fontStyle: 'italic', - margin: '0 0 0 30px', - }, - // Add gutter icon for better visibility - gutterIconSize: 'contain', - overviewRulerColor: 'rgba(0, 255, 0, 0.8)', - overviewRulerLane: vscode.OverviewRulerLane.Right, - }) - - private readonly deletionDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 0, 0, 0.3)', - isWholeLine: true, - border: '2px solid rgba(255, 0, 0, 0.8)', - borderRadius: '3px', - textDecoration: 'line-through', - opacity: '0.6', - after: { - contentText: ' ❌ Removed by Amazon Q', - color: 'rgba(255, 0, 0, 1)', - fontWeight: 'bold', - fontStyle: 'italic', - margin: '0 0 0 30px', - }, - gutterIconSize: 'contain', - overviewRulerColor: 'rgba(255, 0, 0, 0.8)', - overviewRulerLane: vscode.OverviewRulerLane.Right, - }) - - // Highlight decoration for the current animating line - private readonly currentLineDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 255, 0, 0.2)', - isWholeLine: true, - border: '2px solid rgba(255, 255, 0, 1)', - borderRadius: '3px', - }) - - // Fade decoration for completed animations - private readonly fadeDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(0, 255, 0, 0.1)', - border: '1px solid rgba(0, 255, 0, 0.3)', - after: { - contentText: ' ✓', - color: 'rgba(0, 255, 0, 0.6)', - margin: '0 0 0 10px', - }, - }) - private activeAnimations = new Map() + private fadedOverlayControllers = new Map() + private activeLineControllers = new Map() + private additionControllers = new Map() + private deletionControllers = new Map() + private deletionMarkerControllers = new Map() + private streamedLines = new Map() + private lastFirstVisibleLine = new Map() + private shouldAutoScroll = new Map() + private scrollListeners = new Map() private animationTimeouts = new Map() - private animationSpeed = 50 // Faster for better real-time feel - private scrollDelay = 25 // Faster scrolling - private fadeDelay = 3000 // How long to keep fade decorations - private groupProximityLines = 5 // Lines within this distance are grouped constructor() { - getLogger().info('[DiffAnimationController] 🚀 Initialized') + getLogger().info('[DiffAnimationController] 🚀 Initialized with Cline-style streaming and GitHub diff support') } /** - * Start a diff animation for a file + * Start a diff animation for a file using Cline's streaming approach */ public async startDiffAnimation(filePath: string, originalContent: string, newContent: string): Promise { - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting diff animation for: ${filePath}`) - getLogger().info( - `[DiffAnimationController] 📊 Original: ${originalContent.length} chars, New: ${newContent.length} chars` - ) + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting Cline-style animation for: ${filePath}`) try { - // Stop any existing animation for this file - this.stopDiffAnimation(filePath) + // Cancel any existing animation for this file + this.cancelAnimation(filePath) const uri = vscode.Uri.file(filePath) - // Open or create the document + // Store animation state + const animation: DiffAnimation = { + uri, + originalContent, + newContent, + isShowingStaticDiff: false, + animationCancelled: false, + } + this.activeAnimations.set(filePath, animation) + + // Open or find the document let document: vscode.TextDocument let editor: vscode.TextEditor try { - // Try to find if document is already open const openEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (openEditor) { editor = openEditor document = openEditor.document - getLogger().info(`[DiffAnimationController] 📄 Found open editor for: ${filePath}`) } else { - // Open the document document = await vscode.workspace.openTextDocument(uri) editor = await vscode.window.showTextDocument(document, { preview: false, preserveFocus: false, viewColumn: vscode.ViewColumn.Active, }) - getLogger().info(`[DiffAnimationController] 📄 Opened document: ${filePath}`) } } catch (error) { - getLogger().info(`[DiffAnimationController] 🆕 File doesn't exist, creating new file`) - // Create the file with original content first - await vscode.workspace.fs.writeFile(uri, Buffer.from(originalContent)) + // Create new file if it doesn't exist + await vscode.workspace.fs.writeFile(uri, Buffer.from('')) document = await vscode.workspace.openTextDocument(uri) editor = await vscode.window.showTextDocument(document, { preview: false, @@ -135,450 +220,443 @@ export class DiffAnimationController { }) } - // Calculate diff - const changes = diffLines(originalContent, newContent) - getLogger().info(`[DiffAnimationController] 📊 Calculated ${changes.length} change blocks`) - - // Store animation state - const decorations = this.calculateDecorations(changes, document, newContent) - const animation: DiffAnimation = { - uri, - originalContent, - newContent, - decorations, - } - this.activeAnimations.set(filePath, animation) - - // Apply the new content + // Initialize controllers + const fadedOverlayController = new DecorationController('fadedOverlay', editor) + const activeLineController = new DecorationController('activeLine', editor) + const additionController = new DecorationController('addition', editor) + const deletionController = new DecorationController('deletion', editor) + const deletionMarkerController = new DecorationController('deletionMarker', editor) + + this.fadedOverlayControllers.set(filePath, fadedOverlayController) + this.activeLineControllers.set(filePath, activeLineController) + this.additionControllers.set(filePath, additionController) + this.deletionControllers.set(filePath, deletionController) + this.deletionMarkerControllers.set(filePath, deletionMarkerController) + + // Initialize state + this.streamedLines.set(filePath, []) + this.shouldAutoScroll.set(filePath, true) + this.lastFirstVisibleLine.set(filePath, 0) + + // For new files, set content to empty instead of original + const isNewFile = originalContent === '' + const startContent = isNewFile ? '' : originalContent + + // Apply initial content const edit = new vscode.WorkspaceEdit() const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)) - edit.replace(uri, fullRange, newContent) - const applied = await vscode.workspace.applyEdit(edit) - - if (!applied) { - throw new Error('Failed to apply edit') - } + edit.replace(uri, fullRange, startContent) + await vscode.workspace.applyEdit(edit) - // Wait for the document to update + // Wait for document to update await new Promise((resolve) => setTimeout(resolve, 100)) - // Re-get the document and editor after content change - document = await vscode.workspace.openTextDocument(uri) - const currentEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - - if (currentEditor) { - editor = currentEditor - } else { - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Active, - }) + // Apply faded overlay to all lines initially (for new files, this will be minimal) + if (!isNewFile && editor.document.lineCount > 0) { + fadedOverlayController.addLines(0, editor.document.lineCount) } - // Start the animation - await this.animateDiff(editor, decorations, filePath) + // Add scroll detection + const scrollListener = vscode.window.onDidChangeTextEditorVisibleRanges((e) => { + if (e.textEditor === editor) { + const currentFirstVisibleLine = e.visibleRanges[0]?.start.line || 0 + this.lastFirstVisibleLine.set(filePath, currentFirstVisibleLine) + } + }) + this.scrollListeners.set(filePath, scrollListener) + + // Start streaming animation + await this.streamContent(filePath, editor, newContent) } catch (error) { - getLogger().error(`[DiffAnimationController] ❌ Failed to start diff animation: ${error}`) + getLogger().error(`[DiffAnimationController] ❌ Failed to start animation: ${error}`) throw error } } /** - * Support for incremental diff animation (for real-time updates) + * Cancel ongoing animation for a file */ - public async startIncrementalDiffAnimation( - filePath: string, - previousContent: string, - currentContent: string, - isFirstUpdate: boolean = false - ): Promise { - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting incremental animation for: ${filePath}`) + private cancelAnimation(filePath: string): void { + const animation = this.activeAnimations.get(filePath) + if (animation && !animation.isShowingStaticDiff) { + animation.animationCancelled = true + + // Clear any pending timeouts + const timeouts = this.animationTimeouts.get(filePath) + if (timeouts) { + for (const timeout of timeouts) { + clearTimeout(timeout) + } + this.animationTimeouts.delete(filePath) + } - // If it's the first update or empty previous content, use full animation - if (isFirstUpdate || previousContent === '') { - return this.startDiffAnimation(filePath, previousContent, currentContent) + // Clear decorations + this.fadedOverlayControllers.get(filePath)?.clear() + this.activeLineControllers.get(filePath)?.clear() + + getLogger().info(`[DiffAnimationController] âš ī¸ Cancelled ongoing animation for: ${filePath}`) } + } - // For incremental updates, calculate diff from previous state - try { - const uri = vscode.Uri.file(filePath) - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + /** + * Show static GitHub-style diff view for a file + */ + public async showStaticDiffView(filePath: string): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation) { + getLogger().warn(`[DiffAnimationController] No animation data found for: ${filePath}`) + return + } - if (!editor) { - // If editor not found, fall back to full animation - return this.startDiffAnimation(filePath, previousContent, currentContent) - } + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (!editor) { + getLogger().warn(`[DiffAnimationController] No editor found for: ${filePath}`) + return + } - // Calculate incremental changes - const incrementalChanges = diffLines(previousContent, currentContent) - const hasChanges = incrementalChanges.some((change) => change.added || change.removed) + // If already showing diff, toggle back to normal view + if (animation.isShowingStaticDiff && animation.diffViewContent) { + await this.exitDiffView(filePath) + return + } - if (!hasChanges) { - getLogger().info(`[DiffAnimationController] â„šī¸ No changes detected in incremental update`) - return - } + // Clear streaming decorations + this.fadedOverlayControllers.get(filePath)?.clear() + this.activeLineControllers.get(filePath)?.clear() - // Apply content change - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(uri, fullRange, currentContent) - await vscode.workspace.applyEdit(edit) + // Apply GitHub-style diff decorations + await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) - // Calculate decorations for new changes only - const decorations = this.calculateDecorations(incrementalChanges, editor.document, currentContent) + animation.isShowingStaticDiff = true + } - // Animate only the new changes - await this.animateIncrementalDiff(editor, decorations, filePath) - } catch (error) { - getLogger().error( - `[DiffAnimationController] ❌ Incremental animation failed, falling back to full: ${error}` - ) - return this.startDiffAnimation(filePath, previousContent, currentContent) + /** + * Exit diff view and restore final content + */ + public async exitDiffView(filePath: string): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation || !animation.isShowingStaticDiff) { + return } + + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (!editor) { + return + } + + // Clear all decorations + this.additionControllers.get(filePath)?.clear() + this.deletionControllers.get(filePath)?.clear() + + // Restore the final content (without deleted lines) + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(editor.document.uri, fullRange, animation.newContent) + await vscode.workspace.applyEdit(edit) + + animation.isShowingStaticDiff = false + animation.diffViewContent = undefined + + getLogger().info(`[DiffAnimationController] Exited diff view for: ${filePath}`) } /** - * Calculate decorations based on diff changes for the NEW content + * Apply GitHub-style diff decorations with actual deletion lines + * + * This method creates a unified diff view by: + * 1. Building content that includes BOTH added and removed lines + * 2. Applying green highlighting to added lines + * 3. Applying red highlighting with strikethrough to removed lines + * 4. The removed lines are temporarily inserted into the document for visualization + * + * Note: The diff view is temporary - users should not edit while in diff view. + * Call exitDiffView() or click the file tab again to restore the final content. */ - private calculateDecorations( - changes: Change[], - document: vscode.TextDocument, + private async applyGitHubDiffDecorations( + filePath: string, + editor: vscode.TextEditor, + originalContent: string, newContent: string - ): DiffAnimation['decorations'] { - const additions: vscode.DecorationOptions[] = [] - const deletions: vscode.DecorationOptions[] = [] + ): Promise { + const additionController = this.additionControllers.get(filePath) + const deletionController = this.deletionControllers.get(filePath) - // Split new content into lines for accurate mapping - let currentLineInNew = 0 - let currentLineInOld = 0 + if (!additionController || !deletionController) { + return + } - getLogger().info(`[DiffAnimationController] 📊 Document has ${document.lineCount} lines`) + // Calculate diff + const changes = diffLines(originalContent, newContent) + const additions: vscode.Range[] = [] + const deletions: vscode.Range[] = [] - for (let i = 0; i < changes.length; i++) { - const change = changes[i] - const changeLines = change.value.split('\n').filter((line) => line !== '') - const lineCount = changeLines.length + // Build the diff view content with removed lines included + let diffViewContent = '' + let currentLineInDiffView = 0 - getLogger().info( - `[DiffAnimationController] Change[${i}]: ${change.added ? 'ADD' : change.removed ? 'REMOVE' : 'KEEP'} ${lineCount} lines` - ) + for (const change of changes) { + const lines = change.value.split('\n').filter((line) => line !== '') if (change.added) { - // For additions, highlight the lines in the new content - for (let j = 0; j < lineCount && currentLineInNew < document.lineCount; j++) { - try { - const line = document.lineAt(currentLineInNew) - additions.push({ - range: line.range, - hoverMessage: `Added: ${line.text}`, - }) - getLogger().info( - `[DiffAnimationController] ➕ Added line ${currentLineInNew}: "${line.text.substring(0, 50)}..."` - ) - currentLineInNew++ - } catch (error) { - getLogger().warn( - `[DiffAnimationController] âš ī¸ Could not highlight line ${currentLineInNew}: ${error}` - ) - currentLineInNew++ - } + // Added lines - these exist in the new content + for (const line of lines) { + diffViewContent += line + '\n' + additions.push(new vscode.Range(currentLineInDiffView, 0, currentLineInDiffView, line.length)) + currentLineInDiffView++ } } else if (change.removed) { - // For deletions, create decorations at the current line position - for (let j = 0; j < lineCount; j++) { - try { - // Use the current line position for the deletion decoration - const line = document.lineAt(currentLineInNew) - deletions.push({ - range: line.range, - hoverMessage: `Removed: ${changeLines[j]}`, - }) - getLogger().info( - `[DiffAnimationController] ➖ Marked deletion at line ${currentLineInNew}: "${changeLines[j]?.substring(0, 50) || ''}..."` - ) - } catch (error) { - getLogger().warn( - `[DiffAnimationController] âš ī¸ Could not mark deletion at line ${currentLineInNew}: ${error}` - ) - } + // Removed lines - we'll insert these into the view + for (const line of lines) { + diffViewContent += line + '\n' + deletions.push(new vscode.Range(currentLineInDiffView, 0, currentLineInDiffView, line.length)) + currentLineInDiffView++ } - currentLineInOld += lineCount } else { - // Unchanged lines - increment both counters - currentLineInNew += lineCount - currentLineInOld += lineCount + // Unchanged lines + for (const line of lines) { + diffViewContent += line + '\n' + currentLineInDiffView++ + } } } - getLogger().info( - `[DiffAnimationController] 📊 Final decorations: ${additions.length} additions, ${deletions.length} deletions` + // Apply the diff view content + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) ) - return { additions, deletions } - } + edit.replace(editor.document.uri, fullRange, diffViewContent.trimEnd()) + await vscode.workspace.applyEdit(edit) - /** - * Animate diff changes progressively with smooth scrolling - */ - private async animateDiff( - editor: vscode.TextEditor, - decorations: DiffAnimation['decorations'], - filePath: string - ): Promise { - const { additions, deletions } = decorations - const timeouts: NodeJS.Timeout[] = [] + // Wait for document to update + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Apply decorations + additionController.setRanges(additions) + deletionController.setRanges(deletions) getLogger().info( - `[DiffAnimationController] đŸŽŦ Starting animation with ${additions.length} additions, ${deletions.length} deletions` + `[DiffAnimationController] Applied GitHub diff: ${additions.length} additions, ${deletions.length} deletions shown` ) - // Clear previous decorations - editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.deletionDecorationType, []) - editor.setDecorations(this.currentLineDecorationType, []) - - // Show all deletions immediately with red background - if (deletions.length > 0) { - editor.setDecorations(this.deletionDecorationType, deletions) - getLogger().info(`[DiffAnimationController] 🔴 Applied ${deletions.length} deletion decorations`) + // Store that we're showing diff view + const animation = this.activeAnimations.get(filePath) + if (animation) { + animation.isShowingStaticDiff = true + animation.diffViewContent = diffViewContent.trimEnd() } + } - // Group additions by proximity for smoother scrolling - const additionGroups = this.groupAdditionsByProximity(additions) - let currentGroupIndex = 0 - let additionsShown = 0 - - // If no changes to animate, show completion message - if (additions.length === 0 && deletions.length === 0) { - getLogger().info(`[DiffAnimationController] â„šī¸ No changes to animate`) + /** + * Stream content in Cline style + */ + private async streamContent(filePath: string, editor: vscode.TextEditor, newContent: string): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation) { return } - // Animate additions with progressive reveal and smart scrolling - for (let i = 0; i < additions.length; i++) { - const timeout = setTimeout(async () => { - if (!vscode.window.visibleTextEditors.includes(editor)) { - getLogger().warn(`[DiffAnimationController] âš ī¸ Editor closed, stopping animation`) - this.stopDiffAnimation(filePath) - return - } - - const currentAdditions = additions.slice(0, i + 1) - const currentAddition = additions[i] - - // Show all additions up to current - editor.setDecorations(this.additionDecorationType, currentAdditions) - - // Highlight current line being added - editor.setDecorations(this.currentLineDecorationType, [currentAddition]) + const lines = newContent.split('\n') + const streamedLines: string[] = [] + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + const activeLineController = this.activeLineControllers.get(filePath) + const timeouts: NodeJS.Timeout[] = [] - // Clear current line highlight after a short delay - setTimeout(() => { - editor.setDecorations(this.currentLineDecorationType, []) - }, this.animationSpeed * 0.8) + if (!fadedOverlayController || !activeLineController) { + return + } - // Keep deletions visible throughout the animation - editor.setDecorations(this.deletionDecorationType, deletions) + this.animationTimeouts.set(filePath, timeouts) - // Smart scrolling logic - const currentGroup = additionGroups[currentGroupIndex] - const isLastInGroup = currentGroup && i === currentGroup[currentGroup.length - 1].index - const shouldScroll = this.shouldScrollToLine(editor, currentAddition.range) + // For new files, apply the entire content immediately then animate + const isNewFile = animation.originalContent === '' + if (isNewFile) { + const edit = new vscode.WorkspaceEdit() + edit.replace(editor.document.uri, new vscode.Range(0, 0, 0, 0), newContent) + await vscode.workspace.applyEdit(edit) + await new Promise((resolve) => setTimeout(resolve, 100)) - if (shouldScroll || isLastInGroup) { - // Smooth scroll to the line - setTimeout(() => { - if (!vscode.window.visibleTextEditors.includes(editor)) { - return - } + // Apply faded overlay to all lines + fadedOverlayController.addLines(0, editor.document.lineCount) + } - const revealType = this.getRevealType(editor, currentAddition.range, i === 0) - editor.revealRange(currentAddition.range, revealType) + // Simulate streaming by updating decorations progressively + for (let i = 0; i < lines.length; i++) { + if (animation.animationCancelled) { + getLogger().info(`[DiffAnimationController] Animation cancelled for: ${filePath}`) + return + } - // Also set cursor position for better visibility - const newSelection = new vscode.Selection( - currentAddition.range.start, - currentAddition.range.start - ) - editor.selection = newSelection - }, this.scrollDelay) + streamedLines.push(lines[i]) + this.streamedLines.set(filePath, [...streamedLines]) - // Move to next group if we're at the end of current group - if (isLastInGroup && currentGroupIndex < additionGroups.length - 1) { - currentGroupIndex++ - } + const timeout = setTimeout(async () => { + if (animation.animationCancelled) { + return } - additionsShown++ - getLogger().info( - `[DiffAnimationController] đŸŽ¯ Animated ${additionsShown}/${additions.length} additions` - ) - }, i * this.animationSpeed) + // Update decorations for streaming effect + await this.updateStreamingDecorations(filePath, editor, i, lines.length) + }, i * 50) // 50ms delay between lines timeouts.push(timeout) } - // Add final timeout to fade decorations after animation - const fadeTimeout = setTimeout( - () => { - if (!vscode.window.visibleTextEditors.includes(editor)) { - getLogger().warn(`[DiffAnimationController] âš ī¸ Editor closed before fade`) - return + // Final cleanup after all lines are processed + const finalTimeout = setTimeout( + async () => { + if (!animation.animationCancelled) { + await this.finalizeAnimation(filePath, editor) } - - // Gradually fade out decorations - editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.deletionDecorationType, []) - editor.setDecorations(this.fadeDecorationType, [...additions, ...deletions]) - - // Remove all decorations after fade - setTimeout(() => { - editor.setDecorations(this.fadeDecorationType, []) - this.activeAnimations.delete(filePath) - getLogger().info(`[DiffAnimationController] ✅ Animation fully completed for ${filePath}`) - }, this.fadeDelay) - - getLogger().info(`[DiffAnimationController] 🎨 Animation fading for ${filePath}`) }, - additions.length * this.animationSpeed + 500 + lines.length * 50 + 100 ) - timeouts.push(fadeTimeout) - this.animationTimeouts.set(filePath, timeouts) + timeouts.push(finalTimeout) } /** - * Animate incremental changes (optimized for real-time updates) + * Update decorations during streaming */ - private async animateIncrementalDiff( + private async updateStreamingDecorations( + filePath: string, editor: vscode.TextEditor, - decorations: DiffAnimation['decorations'], - filePath: string + currentLineIndex: number, + totalLines: number ): Promise { - const { additions, deletions } = decorations + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + const activeLineController = this.activeLineControllers.get(filePath) + const shouldAutoScroll = this.shouldAutoScroll.get(filePath) ?? true - if (additions.length === 0 && deletions.length === 0) { - getLogger().info(`[DiffAnimationController] â„šī¸ No incremental changes to animate`) + if (!fadedOverlayController || !activeLineController) { return } - // Clear previous decorations - editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.deletionDecorationType, []) - editor.setDecorations(this.currentLineDecorationType, []) - - // Show all changes immediately with a flash effect - const allChanges = [...additions, ...deletions] - editor.setDecorations(this.currentLineDecorationType, allChanges) + // Update decorations + activeLineController.setActiveLine(currentLineIndex) + fadedOverlayController.updateOverlayAfterLine(currentLineIndex, editor.document.lineCount) - // Flash effect - setTimeout(() => { - editor.setDecorations(this.currentLineDecorationType, []) - if (additions.length > 0) { - editor.setDecorations(this.additionDecorationType, additions) + // Handle scrolling + if (shouldAutoScroll) { + if (currentLineIndex % 5 === 0 || currentLineIndex === totalLines - 1) { + this.scrollEditorToLine(editor, currentLineIndex) } - if (deletions.length > 0) { - editor.setDecorations(this.deletionDecorationType, deletions) - } - }, 200) - - // Fade after a shorter delay for incremental updates - setTimeout(() => { - editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.deletionDecorationType, []) - editor.setDecorations(this.fadeDecorationType, allChanges) - - setTimeout(() => { - editor.setDecorations(this.fadeDecorationType, []) - }, this.fadeDelay / 2) - }, 1000) - - // Scroll to first change - const firstChange = allChanges[0] - if (firstChange && this.shouldScrollToLine(editor, firstChange.range)) { - editor.revealRange(firstChange.range, vscode.TextEditorRevealType.InCenterIfOutsideViewport) } } /** - * Group additions by proximity for smarter scrolling + * Finalize animation and show diff */ - private groupAdditionsByProximity( - additions: vscode.DecorationOptions[] - ): Array> { - const groups: Array> = [] - let currentGroup: Array<{ range: vscode.Range; index: number }> = [] + private async finalizeAnimation(filePath: string, editor: vscode.TextEditor): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation || animation.animationCancelled) { + return + } - for (let i = 0; i < additions.length; i++) { - const addition = additions[i] + // Clear streaming decorations + this.fadedOverlayControllers.get(filePath)?.clear() + this.activeLineControllers.get(filePath)?.clear() - if (currentGroup.length === 0) { - currentGroup.push({ range: addition.range, index: i }) - } else { - const lastInGroup = currentGroup[currentGroup.length - 1] - const distance = addition.range.start.line - lastInGroup.range.end.line + // Show GitHub-style diff after animation completes + await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) - // Group additions that are within proximity - if (distance <= this.groupProximityLines) { - currentGroup.push({ range: addition.range, index: i }) - } else { - groups.push(currentGroup) - currentGroup = [{ range: addition.range, index: i }] - } - } - } + // Clear timeouts + this.animationTimeouts.delete(filePath) - if (currentGroup.length > 0) { - groups.push(currentGroup) - } + getLogger().info(`[DiffAnimationController] ✅ Animation completed with diff view for: ${filePath}`) + } - getLogger().info( - `[DiffAnimationController] 📊 Grouped ${additions.length} additions into ${groups.length} groups` + /** + * Scroll editor to line + */ + private scrollEditorToLine(editor: vscode.TextEditor, line: number): void { + const scrollLine = Math.max(0, line - 5) + editor.revealRange( + new vscode.Range(scrollLine, 0, scrollLine, 0), + vscode.TextEditorRevealType.InCenterIfOutsideViewport ) - return groups } /** - * Determine if we should scroll to a line + * Support for incremental diff animation */ - private shouldScrollToLine(editor: vscode.TextEditor, range: vscode.Range): boolean { - const visibleRange = editor.visibleRanges[0] - if (!visibleRange) { - return true + public async startIncrementalDiffAnimation( + filePath: string, + previousContent: string, + currentContent: string, + isFirstUpdate: boolean = false + ): Promise { + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting incremental animation for: ${filePath}`) + + if (isFirstUpdate || previousContent === '') { + return this.startDiffAnimation(filePath, previousContent, currentContent) } - const line = range.start.line - const visibleStart = visibleRange.start.line - const visibleEnd = visibleRange.end.line - const buffer = 5 // Lines of buffer at top/bottom + // Cancel any ongoing animation + this.cancelAnimation(filePath) - // Scroll if line is outside visible range with buffer - return line < visibleStart + buffer || line > visibleEnd - buffer - } + // For incremental updates, apply changes immediately with flash effect + try { + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (!editor) { + return this.startDiffAnimation(filePath, previousContent, currentContent) + } - /** - * Get appropriate reveal type based on context - */ - private getRevealType( - editor: vscode.TextEditor, - range: vscode.Range, - isFirst: boolean - ): vscode.TextEditorRevealType { - const visibleRange = editor.visibleRanges[0] - const targetLine = range.start.line - - if (isFirst) { - // First addition - center it - return vscode.TextEditorRevealType.InCenter - } else if (!visibleRange || targetLine < visibleRange.start.line || targetLine > visibleRange.end.line) { - // Line is outside visible range - center it - return vscode.TextEditorRevealType.InCenter - } else { - // Line is visible - use minimal scrolling - return vscode.TextEditorRevealType.InCenterIfOutsideViewport + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + const activeLineController = this.activeLineControllers.get(filePath) + + if (!fadedOverlayController || !activeLineController) { + return this.startDiffAnimation(filePath, previousContent, currentContent) + } + + // Apply content change + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(editor.document.uri, fullRange, currentContent) + await vscode.workspace.applyEdit(edit) + + // Flash effect for changed lines + const newLines = currentContent.split('\n') + const prevLines = previousContent.split('\n') + const changedLines: number[] = [] + + for (let i = 0; i < Math.max(newLines.length, prevLines.length); i++) { + if (newLines[i] !== prevLines[i]) { + changedLines.push(i) + } + } + + // Apply flash effect + for (const line of changedLines) { + if (line < editor.document.lineCount) { + activeLineController.setActiveLine(line) + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + + // Clear decorations + activeLineController.clear() + fadedOverlayController.clear() + + // Update animation data for the incremental change + const animation = this.activeAnimations.get(filePath) + if (animation) { + animation.originalContent = previousContent + animation.newContent = currentContent + + // Show GitHub-style diff + await this.applyGitHubDiffDecorations(filePath, editor, previousContent, currentContent) + } + } catch (error) { + getLogger().error(`[DiffAnimationController] ❌ Incremental animation failed: ${error}`) + return this.startDiffAnimation(filePath, previousContent, currentContent) } } @@ -588,23 +666,75 @@ export class DiffAnimationController { public stopDiffAnimation(filePath: string): void { getLogger().info(`[DiffAnimationController] 🛑 Stopping animation for: ${filePath}`) - const timeouts = this.animationTimeouts.get(filePath) - if (timeouts) { - for (const timeout of timeouts) { - clearTimeout(timeout) + // If showing diff view, exit it first + const animation = this.activeAnimations.get(filePath) + if (animation?.isShowingStaticDiff) { + // Restore final content before clearing + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (editor && animation.newContent) { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(editor.document.uri, fullRange, animation.newContent) + void vscode.workspace.applyEdit(edit).then( + () => { + getLogger().info(`[DiffAnimationController] Restored final content for: ${filePath}`) + }, + (error) => { + getLogger().error( + `[DiffAnimationController] Failed to restore final content for: ${filePath}: ${error}` + ) + } + ) } - this.animationTimeouts.delete(filePath) } + // Cancel animation if running + this.cancelAnimation(filePath) + + // Clear all state for this file this.activeAnimations.delete(filePath) - // Clear decorations if editor is still open - const editor = vscode.window.visibleTextEditors.find((e) => e.document.fileName === filePath) - if (editor) { - editor.setDecorations(this.additionDecorationType, []) - editor.setDecorations(this.deletionDecorationType, []) - editor.setDecorations(this.currentLineDecorationType, []) - editor.setDecorations(this.fadeDecorationType, []) + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + if (fadedOverlayController) { + fadedOverlayController.clear() + this.fadedOverlayControllers.delete(filePath) + } + + const activeLineController = this.activeLineControllers.get(filePath) + if (activeLineController) { + activeLineController.clear() + this.activeLineControllers.delete(filePath) + } + + const additionController = this.additionControllers.get(filePath) + if (additionController) { + additionController.clear() + this.additionControllers.delete(filePath) + } + + const deletionController = this.deletionControllers.get(filePath) + if (deletionController) { + deletionController.clear() + this.deletionControllers.delete(filePath) + } + + const deletionMarkerController = this.deletionMarkerControllers.get(filePath) + if (deletionMarkerController) { + deletionMarkerController.clear() + this.deletionMarkerControllers.delete(filePath) + } + + this.streamedLines.delete(filePath) + this.shouldAutoScroll.delete(filePath) + this.lastFirstVisibleLine.delete(filePath) + + const scrollListener = this.scrollListeners.get(filePath) + if (scrollListener) { + scrollListener.dispose() + this.scrollListeners.delete(filePath) } } @@ -619,18 +749,19 @@ export class DiffAnimationController { } /** - * Set animation speed (ms per line) + * Check if an animation is currently active for a file */ - public setAnimationSpeed(speed: number): void { - this.animationSpeed = Math.max(10, Math.min(500, speed)) - getLogger().info(`[DiffAnimationController] ⚡ Animation speed set to: ${this.animationSpeed}ms`) + public isAnimating(filePath: string): boolean { + const animation = this.activeAnimations.get(filePath) + return animation ? !animation.isShowingStaticDiff && !animation.animationCancelled : false } /** - * Check if an animation is currently active for a file + * Check if showing static diff for a file */ - public isAnimating(filePath: string): boolean { - return this.activeAnimations.has(filePath) + public isShowingStaticDiff(filePath: string): boolean { + const animation = this.activeAnimations.get(filePath) + return animation?.isShowingStaticDiff ?? false } /** @@ -646,9 +777,10 @@ export class DiffAnimationController { public dispose(): void { getLogger().info('[DiffAnimationController] đŸ’Ĩ Disposing controller') this.stopAllAnimations() - this.additionDecorationType.dispose() - this.deletionDecorationType.dispose() - this.currentLineDecorationType.dispose() - this.fadeDecorationType.dispose() + fadedOverlayDecorationType.dispose() + activeLineDecorationType.dispose() + githubAdditionDecorationType.dispose() + githubDeletionDecorationType.dispose() + deletionMarkerDecorationType.dispose() } } From 5f1717039fa1a9ca82ec68f31db3e387c8762523 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 9 Jun 2025 16:10:12 -0700 Subject: [PATCH 10/32] fix a Type error --- packages/amazonq/src/lsp/chat/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index e31ac900d49..e125d485a72 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -580,7 +580,7 @@ export function registerMessageListeners( // Clean up on extension deactivation export function dispose() { if (diffAnimationHandler) { - diffAnimationHandler.dispose() + void diffAnimationHandler.dispose() diffAnimationHandler = undefined } } From a7ded2379403338edbdfabfd659eec4ef85ac166 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Tue, 10 Jun 2025 10:13:51 -0700 Subject: [PATCH 11/32] optimize the animation scope detection --- .../diffAnimation/diffAnimationController.ts | 312 ++++++++++++++---- 1 file changed, 247 insertions(+), 65 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index f27d5faf190..eede4ae50d9 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -3,9 +3,28 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * DiffAnimationController - Provides Cline-style streaming animations with GitHub diff visualization + * + * Key Optimizations: + * 1. Region-based animation: Only animates changed lines instead of entire file + * 2. Smart diff calculation: Uses efficient diff algorithm to find change boundaries + * 3. Viewport limiting: Caps animation to 100 lines max for performance + * 4. Context awareness: Includes 3 lines before/after changes for better visibility + * 5. Dynamic speed: Faster animation for larger changes (20ms vs 30ms per line) + * 6. Efficient scrolling: Only scrolls when necessary (line not visible) + * + * Animation Flow: + * - Calculate changed region using diffLines + * - Apply new content immediately + * - Overlay only the changed region + * - Animate line-by-line reveal with yellow highlight + * - Show GitHub-style diff after completion + */ + import * as vscode from 'vscode' import { getLogger } from 'aws-core-vscode/shared' -import { diffLines } from 'diff' +import { diffLines, Change } from 'diff' // Decoration controller to manage decoration states class DecorationController { @@ -21,7 +40,7 @@ class DecorationController { this.editor = editor } - getDecoration() { + getDecoration(): vscode.TextEditorDecorationType { switch (this.decorationType) { case 'fadedOverlay': return fadedOverlayDecorationType @@ -36,7 +55,7 @@ class DecorationController { } } - addLines(startIndex: number, numLines: number) { + addLines(startIndex: number, numLines: number): void { // Guard against invalid inputs if (startIndex < 0 || numLines <= 0) { return @@ -53,12 +72,12 @@ class DecorationController { this.editor.setDecorations(this.getDecoration(), this.ranges) } - clear() { + clear(): void { this.ranges = [] this.editor.setDecorations(this.getDecoration(), this.ranges) } - updateOverlayAfterLine(line: number, totalLines: number) { + updateOverlayAfterLine(line: number, totalLines: number): void { // Remove any existing ranges that start at or after the current line this.ranges = this.ranges.filter((range) => range.end.line < line) @@ -76,12 +95,12 @@ class DecorationController { this.editor.setDecorations(this.getDecoration(), this.ranges) } - setActiveLine(line: number) { + setActiveLine(line: number): void { this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)] this.editor.setDecorations(this.getDecoration(), this.ranges) } - setRanges(ranges: vscode.Range[]) { + setRanges(ranges: vscode.Range[]): void { this.ranges = ranges this.editor.setDecorations(this.getDecoration(), this.ranges) } @@ -174,7 +193,8 @@ export class DiffAnimationController { * Start a diff animation for a file using Cline's streaming approach */ public async startDiffAnimation(filePath: string, originalContent: string, newContent: string): Promise { - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting Cline-style animation for: ${filePath}`) + const isNewFile = originalContent === '' + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting animation for: ${filePath} (new file: ${isNewFile})`) try { // Cancel any existing animation for this file @@ -201,6 +221,7 @@ export class DiffAnimationController { if (openEditor) { editor = openEditor document = openEditor.document + getLogger().info(`[DiffAnimationController] Found existing editor for: ${filePath}`) } else { document = await vscode.workspace.openTextDocument(uri) editor = await vscode.window.showTextDocument(document, { @@ -208,9 +229,11 @@ export class DiffAnimationController { preserveFocus: false, viewColumn: vscode.ViewColumn.Active, }) + getLogger().info(`[DiffAnimationController] Opened new editor for: ${filePath}`) } } catch (error) { - // Create new file if it doesn't exist + // File doesn't exist - this shouldn't happen as handler creates it + getLogger().warn(`[DiffAnimationController] File not found, creating: ${filePath}`) await vscode.workspace.fs.writeFile(uri, Buffer.from('')) document = await vscode.workspace.openTextDocument(uri) editor = await vscode.window.showTextDocument(document, { @@ -220,6 +243,15 @@ export class DiffAnimationController { }) } + // Ensure editor is active and visible + if (editor !== vscode.window.activeTextEditor) { + editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Active, + }) + } + // Initialize controllers const fadedOverlayController = new DecorationController('fadedOverlay', editor) const activeLineController = new DecorationController('activeLine', editor) @@ -238,20 +270,20 @@ export class DiffAnimationController { this.shouldAutoScroll.set(filePath, true) this.lastFirstVisibleLine.set(filePath, 0) - // For new files, set content to empty instead of original + // For new files, ensure we start with empty content const isNewFile = originalContent === '' - const startContent = isNewFile ? '' : originalContent - // Apply initial content + // Apply initial content (empty for new files, original for existing) const edit = new vscode.WorkspaceEdit() const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)) - edit.replace(uri, fullRange, startContent) + edit.replace(uri, fullRange, originalContent) await vscode.workspace.applyEdit(edit) // Wait for document to update await new Promise((resolve) => setTimeout(resolve, 100)) - // Apply faded overlay to all lines initially (for new files, this will be minimal) + // For new files, we'll stream from empty + // For existing files, apply overlay to original content if (!isNewFile && editor.document.lineCount > 0) { fadedOverlayController.addLines(0, editor.document.lineCount) } @@ -452,7 +484,7 @@ export class DiffAnimationController { } /** - * Stream content in Cline style + * Stream content in Cline style - optimized for changed regions only */ private async streamContent(filePath: string, editor: vscode.TextEditor, newContent: string): Promise { const animation = this.activeAnimations.get(filePath) @@ -460,8 +492,6 @@ export class DiffAnimationController { return } - const lines = newContent.split('\n') - const streamedLines: string[] = [] const fadedOverlayController = this.fadedOverlayControllers.get(filePath) const activeLineController = this.activeLineControllers.get(filePath) const timeouts: NodeJS.Timeout[] = [] @@ -472,27 +502,103 @@ export class DiffAnimationController { this.animationTimeouts.set(filePath, timeouts) - // For new files, apply the entire content immediately then animate + // For new files, animate everything const isNewFile = animation.originalContent === '' - if (isNewFile) { - const edit = new vscode.WorkspaceEdit() - edit.replace(editor.document.uri, new vscode.Range(0, 0, 0, 0), newContent) - await vscode.workspace.applyEdit(edit) - await new Promise((resolve) => setTimeout(resolve, 100)) - // Apply faded overlay to all lines - fadedOverlayController.addLines(0, editor.document.lineCount) + let firstChangedLine = 0 + let lastChangedLine = 0 + + if (!isNewFile) { + // Calculate the actual changes for existing files + const changeInfo = this.calculateChangeRegions(animation.originalContent, newContent) + firstChangedLine = changeInfo.firstChangedLine + lastChangedLine = changeInfo.lastChangedLine + + // If no changes detected, skip animation + if (firstChangedLine === -1 || lastChangedLine === -1) { + getLogger().info(`[DiffAnimationController] No changes detected, skipping animation`) + // Clear any existing decorations + fadedOverlayController.clear() + activeLineController.clear() + await this.finalizeAnimation(filePath, editor) + return + } + + // Store the changes for later use in diff view + animation.diffViewContent = this.buildDiffViewContent(changeInfo.changes) + } else { + // For new files, all lines are "changed" + const newLines = newContent.split('\n') + firstChangedLine = 0 + lastChangedLine = newLines.length - 1 + } + + getLogger().info( + `[DiffAnimationController] Animating lines ${firstChangedLine} to ${lastChangedLine} ` + + `(${lastChangedLine - firstChangedLine + 1} lines)` + ) + + // Apply the new content immediately + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(editor.document.uri, fullRange, newContent) + await vscode.workspace.applyEdit(edit) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Apply faded overlay only to the changed region + if (!isNewFile) { + fadedOverlayController.clear() + if (lastChangedLine >= firstChangedLine && lastChangedLine >= 0) { + // Apply overlay from first changed line to last + const overlayStart = firstChangedLine + const overlayLines = Math.min( + lastChangedLine - firstChangedLine + 1, + editor.document.lineCount - overlayStart + ) + if (overlayLines > 0 && overlayStart < editor.document.lineCount) { + fadedOverlayController.addLines(overlayStart, overlayLines) + } + } + } else { + // For new files, overlay everything + if (editor.document.lineCount > 0) { + fadedOverlayController.addLines(0, editor.document.lineCount) + } } - // Simulate streaming by updating decorations progressively - for (let i = 0; i < lines.length; i++) { + // Scroll to the first change immediately + this.scrollEditorToLine(editor, firstChangedLine) + + // Animate only the changed region with viewport limits + const maxAnimationLines = 100 // Maximum lines to animate at once + const contextLines = 3 // Lines of context before/after changes + + const animationStartLine = Math.max(0, firstChangedLine - contextLines) + let animationEndLine = Math.min(lastChangedLine + contextLines, editor.document.lineCount - 1) + + // If the change region is too large, focus on the beginning + if (animationEndLine - animationStartLine + 1 > maxAnimationLines) { + animationEndLine = animationStartLine + maxAnimationLines - 1 + getLogger().info( + `[DiffAnimationController] Large change region detected, limiting animation to ${maxAnimationLines} lines` + ) + } + + const totalAnimationLines = Math.max(1, animationEndLine - animationStartLine + 1) + + // Adjust animation speed based on number of lines + const animationSpeed = totalAnimationLines > 50 ? 20 : 30 // Faster for large changes + + for (let i = 0; i < totalAnimationLines; i++) { if (animation.animationCancelled) { getLogger().info(`[DiffAnimationController] Animation cancelled for: ${filePath}`) return } - streamedLines.push(lines[i]) - this.streamedLines.set(filePath, [...streamedLines]) + const currentLine = animationStartLine + i const timeout = setTimeout(async () => { if (animation.animationCancelled) { @@ -500,54 +606,148 @@ export class DiffAnimationController { } // Update decorations for streaming effect - await this.updateStreamingDecorations(filePath, editor, i, lines.length) - }, i * 50) // 50ms delay between lines + await this.updateStreamingDecorationsForRegion( + filePath, + editor, + currentLine, + animationStartLine, + animationEndLine + ) + }, i * animationSpeed) timeouts.push(timeout) } - // Final cleanup after all lines are processed + // Final cleanup after animation const finalTimeout = setTimeout( async () => { if (!animation.animationCancelled) { await this.finalizeAnimation(filePath, editor) } }, - lines.length * 50 + 100 + totalAnimationLines * animationSpeed + 100 ) timeouts.push(finalTimeout) } /** - * Update decorations during streaming + * Build diff view content from changes + */ + private buildDiffViewContent(changes: Change[]): string { + let diffViewContent = '' + + for (const change of changes) { + const lines = change.value.split('\n').filter((line) => line !== '') + for (const line of lines) { + diffViewContent += line + '\n' + } + } + + return diffViewContent.trimEnd() + } + + /** + * Calculate change regions for efficient animation */ - private async updateStreamingDecorations( + private calculateChangeRegions( + originalContent: string, + newContent: string + ): { + firstChangedLine: number + lastChangedLine: number + changes: Change[] + } { + const changes = diffLines(originalContent, newContent) + let currentLine = 0 + let firstChangedLine = -1 + let lastChangedLine = -1 + + for (const change of changes) { + const lines = change.value.split('\n').filter((line) => line !== '') + + if (change.added || change.removed) { + if (firstChangedLine === -1) { + firstChangedLine = currentLine + } + // For removed lines, don't advance currentLine, but track as changed + if (change.removed) { + lastChangedLine = Math.max(lastChangedLine, currentLine) + } else { + lastChangedLine = currentLine + lines.length - 1 + } + } + + // Only advance line counter for non-removed content + if (!change.removed) { + currentLine += lines.length + } + } + + return { + firstChangedLine, + lastChangedLine, + changes, + } + } + + /** + * Update decorations during streaming for a specific region + */ + private async updateStreamingDecorationsForRegion( filePath: string, editor: vscode.TextEditor, - currentLineIndex: number, - totalLines: number + currentLine: number, + startLine: number, + endLine: number ): Promise { const fadedOverlayController = this.fadedOverlayControllers.get(filePath) const activeLineController = this.activeLineControllers.get(filePath) const shouldAutoScroll = this.shouldAutoScroll.get(filePath) ?? true + const animation = this.activeAnimations.get(filePath) - if (!fadedOverlayController || !activeLineController) { + if (!fadedOverlayController || !activeLineController || !animation) { return } - // Update decorations - activeLineController.setActiveLine(currentLineIndex) - fadedOverlayController.updateOverlayAfterLine(currentLineIndex, editor.document.lineCount) + // Clear previous active line + activeLineController.clear() + + // Highlight the current line + activeLineController.setActiveLine(currentLine) + + // Update overlay - only for the animated region + fadedOverlayController.clear() + if (currentLine < endLine) { + const remainingLines = endLine - currentLine + fadedOverlayController.addLines(currentLine + 1, remainingLines) + } - // Handle scrolling + // Smart scrolling - only when needed if (shouldAutoScroll) { - if (currentLineIndex % 5 === 0 || currentLineIndex === totalLines - 1) { - this.scrollEditorToLine(editor, currentLineIndex) + const visibleRanges = editor.visibleRanges + const isLineVisible = visibleRanges.some( + (range) => currentLine >= range.start.line && currentLine <= range.end.line + ) + + // Only scroll if line is not visible or at edges + if (!isLineVisible || currentLine === startLine || currentLine === endLine) { + this.scrollEditorToLine(editor, currentLine) } } } + /** + * Scroll editor to line + */ + private scrollEditorToLine(editor: vscode.TextEditor, line: number): void { + const scrollLine = Math.max(0, line - 5) + editor.revealRange( + new vscode.Range(scrollLine, 0, scrollLine, 0), + vscode.TextEditorRevealType.InCenterIfOutsideViewport + ) + } + /** * Finalize animation and show diff */ @@ -570,17 +770,6 @@ export class DiffAnimationController { getLogger().info(`[DiffAnimationController] ✅ Animation completed with diff view for: ${filePath}`) } - /** - * Scroll editor to line - */ - private scrollEditorToLine(editor: vscode.TextEditor, line: number): void { - const scrollLine = Math.max(0, line - 5) - editor.revealRange( - new vscode.Range(scrollLine, 0, scrollLine, 0), - vscode.TextEditorRevealType.InCenterIfOutsideViewport - ) - } - /** * Support for incremental diff animation */ @@ -678,16 +867,9 @@ export class DiffAnimationController { editor.document.positionAt(editor.document.getText().length) ) edit.replace(editor.document.uri, fullRange, animation.newContent) - void vscode.workspace.applyEdit(edit).then( - () => { - getLogger().info(`[DiffAnimationController] Restored final content for: ${filePath}`) - }, - (error) => { - getLogger().error( - `[DiffAnimationController] Failed to restore final content for: ${filePath}: ${error}` - ) - } - ) + void vscode.workspace.applyEdit(edit).then(() => { + getLogger().info(`[DiffAnimationController] Restored final content for: ${filePath}`) + }) } } From 03d94f8ac492aacd3815b6d972a03106aa1b0e22 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Wed, 11 Jun 2025 10:53:17 -0700 Subject: [PATCH 12/32] Apply vscode's diffview and display static diff when clicking on file tab in chatbox --- .../diffAnimation/diffAnimationController.ts | 1075 +++++++++++------ .../diffAnimation/diffAnimationHandler.ts | 768 ++++-------- packages/amazonq/src/lsp/chat/messages.ts | 29 +- 3 files changed, 952 insertions(+), 920 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index eede4ae50d9..768a0596d4c 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -24,7 +24,9 @@ import * as vscode from 'vscode' import { getLogger } from 'aws-core-vscode/shared' -import { diffLines, Change } from 'diff' +import { diffLines } from 'diff' + +const diffViewUriScheme = 'amazon-q-diff' // Decoration controller to manage decoration states class DecorationController { @@ -163,6 +165,26 @@ const deletionMarkerDecorationType = vscode.window.createTextEditorDecorationTyp overviewRulerLane: vscode.OverviewRulerLane.Left, }) +// Content provider for the left side of diff view +class DiffContentProvider implements vscode.TextDocumentContentProvider { + private content = new Map() + private _onDidChange = new vscode.EventEmitter() + readonly onDidChange = this._onDidChange.event + + setContent(uri: string, content: string): void { + this.content.set(uri, content) + this._onDidChange.fire(vscode.Uri.parse(uri)) + } + + provideTextDocumentContent(uri: vscode.Uri): string { + return this.content.get(uri.toString()) || '' + } + + dispose(): void { + this._onDidChange.dispose() + } +} + export interface DiffAnimation { uri: vscode.Uri originalContent: string @@ -170,6 +192,18 @@ export interface DiffAnimation { isShowingStaticDiff?: boolean animationCancelled?: boolean diffViewContent?: string // Store the diff view content + isFromChatClick?: boolean // Add this to track if opened from chat +} + +export interface PartialUpdateOptions { + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } + searchContent?: string // The content being searched for + isPartialUpdate?: boolean // Whether this is a partial update vs full file } export class DiffAnimationController { @@ -184,22 +218,110 @@ export class DiffAnimationController { private shouldAutoScroll = new Map() private scrollListeners = new Map() private animationTimeouts = new Map() + private fileSnapshots = new Map() // Store file content before animation + private hiddenEditors = new Map() // Store hidden editors for undo preservation + + // Track file animation history for intelligent diff display + private fileAnimationHistory = new Map< + string, + { + lastAnimatedContent: string + animationCount: number + isCurrentlyAnimating: boolean + } + >() + + // Content provider for diff view + private contentProvider: DiffContentProvider + private providerDisposable: vscode.Disposable constructor() { getLogger().info('[DiffAnimationController] 🚀 Initialized with Cline-style streaming and GitHub diff support') + + // Initialize content provider for diff view + this.contentProvider = new DiffContentProvider() + this.providerDisposable = vscode.workspace.registerTextDocumentContentProvider( + diffViewUriScheme, + this.contentProvider + ) + } + + /** + * Check if we should show static diff for a file + */ + public shouldShowStaticDiff(filePath: string, newContent: string): boolean { + const history = this.fileAnimationHistory.get(filePath) + if (!history) { + return false // Never animated before + } + + // If currently animating, don't show static diff + if (history.isCurrentlyAnimating) { + return false + } + + // If content is the same as last animated content, show static diff + return history.lastAnimatedContent === newContent + } + + /** + * Update animation history when starting animation + */ + private updateAnimationStart(filePath: string): void { + const history = this.fileAnimationHistory.get(filePath) || { + lastAnimatedContent: '', + animationCount: 0, + isCurrentlyAnimating: false, + } + + history.isCurrentlyAnimating = true + history.animationCount++ + this.fileAnimationHistory.set(filePath, history) + } + + /** + * Update animation history when completing animation + */ + private updateAnimationComplete(filePath: string, finalContent: string): void { + const history = this.fileAnimationHistory.get(filePath) + if (history) { + history.isCurrentlyAnimating = false + history.lastAnimatedContent = finalContent + this.fileAnimationHistory.set(filePath, history) + } } /** * Start a diff animation for a file using Cline's streaming approach */ - public async startDiffAnimation(filePath: string, originalContent: string, newContent: string): Promise { + /** + * Start a diff animation for a file using Cline's streaming approach + */ + public async startDiffAnimation( + filePath: string, + originalContent: string, + newContent: string, + isFromChatClick: boolean = false + ): Promise { const isNewFile = originalContent === '' - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting animation for: ${filePath} (new file: ${isNewFile})`) + getLogger().info( + `[DiffAnimationController] đŸŽŦ Starting animation for: ${filePath} (new file: ${isNewFile}, from chat: ${isFromChatClick})` + ) + + // Check if we should show static diff instead + if (isFromChatClick && this.shouldShowStaticDiff(filePath, newContent)) { + getLogger().info(`[DiffAnimationController] Content unchanged, showing static diff`) + await this.showStaticDiffView(filePath) + return + } try { // Cancel any existing animation for this file this.cancelAnimation(filePath) + // Mark animation as started + this.updateAnimationStart(filePath) + const uri = vscode.Uri.file(filePath) // Store animation state @@ -209,102 +331,253 @@ export class DiffAnimationController { newContent, isShowingStaticDiff: false, animationCancelled: false, + isFromChatClick, } this.activeAnimations.set(filePath, animation) - // Open or find the document - let document: vscode.TextDocument - let editor: vscode.TextEditor + // Ensure the file exists and apply the new content + let doc: vscode.TextDocument try { - const openEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (openEditor) { - editor = openEditor - document = openEditor.document - getLogger().info(`[DiffAnimationController] Found existing editor for: ${filePath}`) - } else { - document = await vscode.workspace.openTextDocument(uri) - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Active, - }) - getLogger().info(`[DiffAnimationController] Opened new editor for: ${filePath}`) - } - } catch (error) { - // File doesn't exist - this shouldn't happen as handler creates it - getLogger().warn(`[DiffAnimationController] File not found, creating: ${filePath}`) + // Try to open existing file + doc = await vscode.workspace.openTextDocument(uri) + // Store current content as snapshot + this.fileSnapshots.set(filePath, doc.getText()) + } catch { + // File doesn't exist, create it with empty content first await vscode.workspace.fs.writeFile(uri, Buffer.from('')) - document = await vscode.workspace.openTextDocument(uri) - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Active, - }) + doc = await vscode.workspace.openTextDocument(uri) + this.fileSnapshots.set(filePath, '') + getLogger().info(`[DiffAnimationController] Created new file: ${filePath}`) } - // Ensure editor is active and visible - if (editor !== vscode.window.activeTextEditor) { - editor = await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Active, - }) + // Apply the new content using WorkspaceEdit (this preserves undo history) + // Do this WITHOUT opening a visible editor + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + 0, + 0, + doc.lineCount > 0 ? doc.lineCount - 1 : 0, + doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 + ) + edit.replace(uri, fullRange, newContent) + + // Apply edit with undo support + const success = await vscode.workspace.applyEdit(edit) + if (!success) { + throw new Error('Failed to apply edit to file') + } + + // Save the document to ensure changes are persisted + await doc.save() + + // Now open the diff view for animation + const diffEditor = await this.openClineDiffView(filePath, originalContent, isNewFile) + if (!diffEditor) { + throw new Error('Failed to open diff view') } // Initialize controllers - const fadedOverlayController = new DecorationController('fadedOverlay', editor) - const activeLineController = new DecorationController('activeLine', editor) - const additionController = new DecorationController('addition', editor) - const deletionController = new DecorationController('deletion', editor) - const deletionMarkerController = new DecorationController('deletionMarker', editor) + const fadedOverlayController = new DecorationController('fadedOverlay', diffEditor) + const activeLineController = new DecorationController('activeLine', diffEditor) this.fadedOverlayControllers.set(filePath, fadedOverlayController) this.activeLineControllers.set(filePath, activeLineController) - this.additionControllers.set(filePath, additionController) - this.deletionControllers.set(filePath, deletionController) - this.deletionMarkerControllers.set(filePath, deletionMarkerController) // Initialize state this.streamedLines.set(filePath, []) this.shouldAutoScroll.set(filePath, true) this.lastFirstVisibleLine.set(filePath, 0) - // For new files, ensure we start with empty content - const isNewFile = originalContent === '' - - // Apply initial content (empty for new files, original for existing) - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)) - edit.replace(uri, fullRange, originalContent) - await vscode.workspace.applyEdit(edit) - - // Wait for document to update - await new Promise((resolve) => setTimeout(resolve, 100)) - - // For new files, we'll stream from empty - // For existing files, apply overlay to original content - if (!isNewFile && editor.document.lineCount > 0) { - fadedOverlayController.addLines(0, editor.document.lineCount) - } - // Add scroll detection const scrollListener = vscode.window.onDidChangeTextEditorVisibleRanges((e) => { - if (e.textEditor === editor) { + if (e.textEditor === diffEditor) { const currentFirstVisibleLine = e.visibleRanges[0]?.start.line || 0 + const lastLine = this.lastFirstVisibleLine.get(filePath) || 0 + + // If user scrolled up, disable auto-scroll + if (currentFirstVisibleLine < lastLine) { + this.shouldAutoScroll.set(filePath, false) + } + this.lastFirstVisibleLine.set(filePath, currentFirstVisibleLine) } }) this.scrollListeners.set(filePath, scrollListener) - // Start streaming animation - await this.streamContent(filePath, editor, newContent) + // Calculate changed region for optimization + const changedRegion = this.calculateChangedRegion(originalContent, newContent) + getLogger().info( + `[DiffAnimationController] Changed region: lines ${changedRegion.startLine}-${changedRegion.endLine}` + ) + + // Start streaming animation (Cline style) - visual only + await this.streamContentClineStyle(filePath, diffEditor, newContent, animation, changedRegion) } catch (error) { getLogger().error(`[DiffAnimationController] ❌ Failed to start animation: ${error}`) + // Restore file content on error using WorkspaceEdit + const snapshot = this.fileSnapshots.get(filePath) + if (snapshot !== undefined) { + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + 0, + 0, + doc.lineCount > 0 ? doc.lineCount - 1 : 0, + doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 + ) + edit.replace(doc.uri, fullRange, snapshot) + await vscode.workspace.applyEdit(edit) + } catch (restoreError) { + getLogger().error(`[DiffAnimationController] Failed to restore content: ${restoreError}`) + } + } + this.stopDiffAnimation(filePath) throw error } } + /** + * Close the diff view and return to normal file view + */ + private async closeDiffView(filePath: string): Promise { + try { + // Find all visible editors + const editors = vscode.window.visibleTextEditors + + // Find the diff editor (it will have the special URI scheme) + const diffEditor = editors.find( + (e) => e.document.uri.scheme === diffViewUriScheme || e.document.uri.fsPath === filePath + ) + + if (diffEditor) { + // Close the diff view + await vscode.commands.executeCommand('workbench.action.closeActiveEditor') + + // Open the file normally after diff view is closed + const uri = vscode.Uri.file(filePath) + const doc = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(doc, { preview: false }) + + getLogger().info( + `[DiffAnimationController] Closed diff view and opened normal file view for: ${filePath}` + ) + } + } catch (error) { + getLogger().error(`[DiffAnimationController] Error closing diff view: ${error}`) + } + } + + /** + * Calculate the changed region between original and new content + */ + private calculateChangedRegion( + originalContent: string, + newContent: string + ): { startLine: number; endLine: number; totalLines: number } { + // For new files, animate all lines + if (!originalContent || originalContent === '') { + const lines = newContent.split('\n') + return { + startLine: 0, + endLine: Math.min(lines.length - 1, 99), // Cap at 100 lines + totalLines: lines.length, + } + } + + const changes = diffLines(originalContent, newContent) + let minChangedLine = Infinity + let maxChangedLine = -1 + let currentLine = 0 + const newLines = newContent.split('\n') + + for (const change of changes) { + const changeLines = change.value.split('\n') + // Remove empty last element from split + if (changeLines[changeLines.length - 1] === '') { + changeLines.pop() + } + + if (change.added || change.removed) { + minChangedLine = Math.min(minChangedLine, currentLine) + maxChangedLine = Math.max(maxChangedLine, currentLine + changeLines.length - 1) + } + + if (!change.removed) { + currentLine += changeLines.length + } + } + + // If no changes found, animate the whole file + if (minChangedLine === Infinity) { + return { + startLine: 0, + endLine: Math.min(newLines.length - 1, 99), + totalLines: newLines.length, + } + } + + // Add context lines (3 before and after) + const contextLines = 3 + const startLine = Math.max(0, minChangedLine - contextLines) + const endLine = Math.min(newLines.length - 1, maxChangedLine + contextLines) + + // Cap at 100 lines for performance + const animationLines = endLine - startLine + 1 + if (animationLines > 100) { + getLogger().info(`[DiffAnimationController] Capping animation from ${animationLines} to 100 lines`) + return { + startLine, + endLine: startLine + 99, + totalLines: newLines.length, + } + } + + return { + startLine, + endLine, + totalLines: newLines.length, + } + } + + /** + * Start partial diff animation for specific changes + */ + public async startPartialDiffAnimation( + filePath: string, + originalContent: string, + newContent: string, + options: PartialUpdateOptions = {} + ): Promise { + const { changeLocation, searchContent, isPartialUpdate = false } = options + + getLogger().info(`[DiffAnimationController] đŸŽŦ Starting partial animation for: ${filePath}`) + + // If we have a specific change location, we can optimize the animation + if (changeLocation && isPartialUpdate) { + // Check if we already have a diff view open + const existingAnimation = this.activeAnimations.get(filePath) + const existingEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + + if (existingAnimation && existingEditor) { + // Update only the changed portion + await this.updatePartialContent( + filePath, + existingEditor, + existingAnimation, + changeLocation, + searchContent || '', + newContent + ) + return + } + } + + // Fall back to full animation if no optimization possible + return this.startDiffAnimation(filePath, originalContent, newContent) + } + /** * Cancel ongoing animation for a file */ @@ -331,409 +604,439 @@ export class DiffAnimationController { } /** - * Show static GitHub-style diff view for a file + * Open VS Code diff view (Cline style) */ - public async showStaticDiffView(filePath: string): Promise { - const animation = this.activeAnimations.get(filePath) - if (!animation) { - getLogger().warn(`[DiffAnimationController] No animation data found for: ${filePath}`) - return - } + private async openClineDiffView( + filePath: string, + originalContent: string, + isNewFile: boolean + ): Promise { + const fileName = filePath.split(/[\\\/]/).pop() || 'file' + const leftUri = vscode.Uri.parse(`${diffViewUriScheme}:${fileName}`).with({ + query: Buffer.from(originalContent).toString('base64'), + }) - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (!editor) { - getLogger().warn(`[DiffAnimationController] No editor found for: ${filePath}`) - return - } + // Set content for left side + this.contentProvider.setContent(leftUri.toString(), originalContent) - // If already showing diff, toggle back to normal view - if (animation.isShowingStaticDiff && animation.diffViewContent) { - await this.exitDiffView(filePath) - return - } + // Right side is the actual file + const rightUri = vscode.Uri.file(filePath) - // Clear streaming decorations - this.fadedOverlayControllers.get(filePath)?.clear() - this.activeLineControllers.get(filePath)?.clear() + // DO NOT clear the right side content - it already has the final content + // This preserves the undo history + // await vscode.workspace.fs.writeFile(rightUri, Buffer.from('')) - // Apply GitHub-style diff decorations - await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) + const title = `${fileName}: ${isNewFile ? 'New File' : "Original ↔ AI's Changes"} (Streaming...)` - animation.isShowingStaticDiff = true + // Execute diff command + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { + preview: false, + preserveFocus: false, + }) + + // Wait a bit for the diff view to open + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Find the editor for the right side (the actual file) + let editor = vscode.window.activeTextEditor + if (editor && editor.document.uri.fsPath === filePath) { + return editor + } + + // Fallback: find editor by URI + editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (editor) { + return editor + } + + // Another attempt after a short delay + await new Promise((resolve) => setTimeout(resolve, 100)) + return vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) } /** - * Exit diff view and restore final content + * Stream content line by line (Cline style) with optimization for changed region */ - public async exitDiffView(filePath: string): Promise { - const animation = this.activeAnimations.get(filePath) - if (!animation || !animation.isShowingStaticDiff) { + private async streamContentClineStyle( + filePath: string, + editor: vscode.TextEditor, + newContent: string, + animation: DiffAnimation, + changedRegion: { startLine: number; endLine: number; totalLines: number } + ): Promise { + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + const activeLineController = this.activeLineControllers.get(filePath) + + if (!fadedOverlayController || !activeLineController || animation.animationCancelled) { return } - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (!editor) { - return + // The file already has the new content, we just animate the visual effect + const totalLines = editor.document.lineCount + + // Apply initial faded overlay to simulate hidden content + fadedOverlayController.addLines(0, totalLines) + + // Animate the reveal effect + for (let i = 0; i <= changedRegion.endLine && i < totalLines && !animation.animationCancelled; i++) { + // Update decorations to show line-by-line reveal + activeLineController.setActiveLine(i) + fadedOverlayController.updateOverlayAfterLine(i, totalLines) + + // Auto-scroll if enabled + if (this.shouldAutoScroll.get(filePath) !== false && i >= changedRegion.startLine) { + this.scrollEditorToLine(editor, i) + } + + // Animation delay (only for changed region) + if (i >= changedRegion.startLine && i <= changedRegion.endLine) { + const delay = changedRegion.endLine - changedRegion.startLine > 50 ? 20 : 30 + await new Promise((resolve) => setTimeout(resolve, delay)) + } } - // Clear all decorations - this.additionControllers.get(filePath)?.clear() - this.deletionControllers.get(filePath)?.clear() + // Clear decorations when done + fadedOverlayController.clear() + activeLineController.clear() - // Restore the final content (without deleted lines) - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, animation.newContent) - await vscode.workspace.applyEdit(edit) + // Apply GitHub-style diff decorations + await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) - animation.isShowingStaticDiff = false - animation.diffViewContent = undefined + animation.animationCancelled = false - getLogger().info(`[DiffAnimationController] Exited diff view for: ${filePath}`) + // Update animation history + this.updateAnimationComplete(filePath, animation.newContent) + + getLogger().info(`[DiffAnimationController] ✅ Animation completed for: ${filePath}`) + + // Auto-close diff view after animation completes (unless opened from chat) + if (!animation.isFromChatClick) { + getLogger().info(`[DiffAnimationController] Auto-closing diff view for: ${filePath}`) + await this.closeDiffView(filePath) + } } /** - * Apply GitHub-style diff decorations with actual deletion lines - * - * This method creates a unified diff view by: - * 1. Building content that includes BOTH added and removed lines - * 2. Applying green highlighting to added lines - * 3. Applying red highlighting with strikethrough to removed lines - * 4. The removed lines are temporarily inserted into the document for visualization - * - * Note: The diff view is temporary - users should not edit while in diff view. - * Call exitDiffView() or click the file tab again to restore the final content. + * Update only a portion of the file */ - private async applyGitHubDiffDecorations( + private async updatePartialContent( filePath: string, editor: vscode.TextEditor, - originalContent: string, + animation: DiffAnimation, + changeLocation: { startLine: number; endLine: number }, + searchContent: string, newContent: string ): Promise { - const additionController = this.additionControllers.get(filePath) - const deletionController = this.deletionControllers.get(filePath) + const fadedOverlayController = this.fadedOverlayControllers.get(filePath) + const activeLineController = this.activeLineControllers.get(filePath) - if (!additionController || !deletionController) { + if (!fadedOverlayController || !activeLineController) { return } - // Calculate diff - const changes = diffLines(originalContent, newContent) - const additions: vscode.Range[] = [] - const deletions: vscode.Range[] = [] + getLogger().info( + `[DiffAnimationController] 📝 Partial update at lines ${changeLocation.startLine}-${changeLocation.endLine}` + ) - // Build the diff view content with removed lines included - let diffViewContent = '' - let currentLineInDiffView = 0 + // Find the exact location in the current document + const document = editor.document + let matchStartLine = -1 - for (const change of changes) { - const lines = change.value.split('\n').filter((line) => line !== '') + if (searchContent) { + // Search for the exact content in the document + const documentText = document.getText() + const searchIndex = documentText.indexOf(searchContent) - if (change.added) { - // Added lines - these exist in the new content - for (const line of lines) { - diffViewContent += line + '\n' - additions.push(new vscode.Range(currentLineInDiffView, 0, currentLineInDiffView, line.length)) - currentLineInDiffView++ - } - } else if (change.removed) { - // Removed lines - we'll insert these into the view - for (const line of lines) { - diffViewContent += line + '\n' - deletions.push(new vscode.Range(currentLineInDiffView, 0, currentLineInDiffView, line.length)) - currentLineInDiffView++ - } - } else { - // Unchanged lines - for (const line of lines) { - diffViewContent += line + '\n' - currentLineInDiffView++ - } + if (searchIndex !== -1) { + // Convert character index to line number + const textBefore = documentText.substring(0, searchIndex) + matchStartLine = (textBefore.match(/\n/g) || []).length } + } else { + // Use the provided line number directly + matchStartLine = changeLocation.startLine } - // Apply the diff view content - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, diffViewContent.trimEnd()) - await vscode.workspace.applyEdit(edit) + if (matchStartLine === -1) { + getLogger().warn(`[DiffAnimationController] Could not find search content, falling back to full scan`) + return this.startDiffAnimation(filePath, animation.originalContent, newContent) + } - // Wait for document to update - await new Promise((resolve) => setTimeout(resolve, 100)) + // Calculate the replacement + const searchLines = searchContent.split('\n') + const replacementLines = this.extractReplacementContent(animation.originalContent, newContent, searchContent) - // Apply decorations - additionController.setRanges(additions) - deletionController.setRanges(deletions) + // Apply the edit using WorkspaceEdit for undo support + const edit = new vscode.WorkspaceEdit() + const startPos = new vscode.Position(matchStartLine, 0) + const endPos = new vscode.Position(matchStartLine + searchLines.length, 0) + const range = new vscode.Range(startPos, endPos) - getLogger().info( - `[DiffAnimationController] Applied GitHub diff: ${additions.length} additions, ${deletions.length} deletions shown` + edit.replace(editor.document.uri, range, replacementLines.join('\n') + '\n') + await vscode.workspace.applyEdit(edit) + + // Animate only the changed lines + await this.animatePartialChange( + editor, + fadedOverlayController, + activeLineController, + matchStartLine, + replacementLines.length ) - // Store that we're showing diff view - const animation = this.activeAnimations.get(filePath) - if (animation) { - animation.isShowingStaticDiff = true - animation.diffViewContent = diffViewContent.trimEnd() + // Scroll to the change + if (this.shouldAutoScroll.get(filePath) !== false) { + this.scrollEditorToLine(editor, matchStartLine) } + + // Update animation state + animation.newContent = document.getText() } /** - * Stream content in Cline style - optimized for changed regions only + * Extract replacement content */ - private async streamContent(filePath: string, editor: vscode.TextEditor, newContent: string): Promise { - const animation = this.activeAnimations.get(filePath) - if (!animation) { - return + private extractReplacementContent(originalContent: string, newContent: string, searchContent: string): string[] { + // This would use the SEARCH/REPLACE logic to extract just the replacement portion + const newLines = newContent.split('\n') + const searchLines = searchContent.split('\n') + + // Find where the change starts in the new content + let startIndex = 0 + for (let i = 0; i < newLines.length; i++) { + if (newLines.slice(i, i + searchLines.length).join('\n') !== searchContent) { + startIndex = i + break + } } - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - const activeLineController = this.activeLineControllers.get(filePath) - const timeouts: NodeJS.Timeout[] = [] - - if (!fadedOverlayController || !activeLineController) { - return - } + // Extract the replacement lines + return newLines.slice(startIndex, startIndex + searchLines.length) + } - this.animationTimeouts.set(filePath, timeouts) + /** + * Animate only the changed portion + */ + private async animatePartialChange( + editor: vscode.TextEditor, + fadedOverlayController: DecorationController, + activeLineController: DecorationController, + startLine: number, + lineCount: number + ): Promise { + // Clear previous decorations + fadedOverlayController.clear() + activeLineController.clear() - // For new files, animate everything - const isNewFile = animation.originalContent === '' + // Apply overlay only to the changed region + fadedOverlayController.addLines(startLine, lineCount) - let firstChangedLine = 0 - let lastChangedLine = 0 + // Animate the changed lines + for (let i = 0; i < lineCount; i++) { + const currentLine = startLine + i - if (!isNewFile) { - // Calculate the actual changes for existing files - const changeInfo = this.calculateChangeRegions(animation.originalContent, newContent) - firstChangedLine = changeInfo.firstChangedLine - lastChangedLine = changeInfo.lastChangedLine + // Highlight current line + activeLineController.setActiveLine(currentLine) - // If no changes detected, skip animation - if (firstChangedLine === -1 || lastChangedLine === -1) { - getLogger().info(`[DiffAnimationController] No changes detected, skipping animation`) - // Clear any existing decorations + // Update overlay + if (i < lineCount - 1) { fadedOverlayController.clear() - activeLineController.clear() - await this.finalizeAnimation(filePath, editor) - return + fadedOverlayController.addLines(currentLine + 1, lineCount - i - 1) } - // Store the changes for later use in diff view - animation.diffViewContent = this.buildDiffViewContent(changeInfo.changes) - } else { - // For new files, all lines are "changed" - const newLines = newContent.split('\n') - firstChangedLine = 0 - lastChangedLine = newLines.length - 1 + // Animation delay + await new Promise((resolve) => setTimeout(resolve, 30)) } - getLogger().info( - `[DiffAnimationController] Animating lines ${firstChangedLine} to ${lastChangedLine} ` + - `(${lastChangedLine - firstChangedLine + 1} lines)` - ) + // Clear decorations + fadedOverlayController.clear() + activeLineController.clear() - // Apply the new content immediately - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, newContent) - await vscode.workspace.applyEdit(edit) - await new Promise((resolve) => setTimeout(resolve, 100)) + // Apply GitHub diff decorations to the changed region + await this.applyPartialGitHubDiffDecorations(editor, startLine, lineCount) + } - // Apply faded overlay only to the changed region - if (!isNewFile) { - fadedOverlayController.clear() - if (lastChangedLine >= firstChangedLine && lastChangedLine >= 0) { - // Apply overlay from first changed line to last - const overlayStart = firstChangedLine - const overlayLines = Math.min( - lastChangedLine - firstChangedLine + 1, - editor.document.lineCount - overlayStart - ) - if (overlayLines > 0 && overlayStart < editor.document.lineCount) { - fadedOverlayController.addLines(overlayStart, overlayLines) - } - } - } else { - // For new files, overlay everything - if (editor.document.lineCount > 0) { - fadedOverlayController.addLines(0, editor.document.lineCount) - } + /** + * Apply diff decorations only to changed region + */ + private async applyPartialGitHubDiffDecorations( + editor: vscode.TextEditor, + startLine: number, + lineCount: number + ): Promise { + const additions: vscode.Range[] = [] + + // Mark all changed lines as additions + for (let i = 0; i < lineCount && startLine + i < editor.document.lineCount; i++) { + additions.push(new vscode.Range(startLine + i, 0, startLine + i, Number.MAX_SAFE_INTEGER)) } - // Scroll to the first change immediately - this.scrollEditorToLine(editor, firstChangedLine) + // Get or create addition controller + let additionController = this.additionControllers.get(editor.document.uri.fsPath) + if (!additionController) { + additionController = new DecorationController('addition', editor) + this.additionControllers.set(editor.document.uri.fsPath, additionController) + } - // Animate only the changed region with viewport limits - const maxAnimationLines = 100 // Maximum lines to animate at once - const contextLines = 3 // Lines of context before/after changes + // Apply decorations + additionController.setRanges(additions) - const animationStartLine = Math.max(0, firstChangedLine - contextLines) - let animationEndLine = Math.min(lastChangedLine + contextLines, editor.document.lineCount - 1) + getLogger().info(`[DiffAnimationController] Applied partial diff decorations: ${additions.length} additions`) + } - // If the change region is too large, focus on the beginning - if (animationEndLine - animationStartLine + 1 > maxAnimationLines) { - animationEndLine = animationStartLine + maxAnimationLines - 1 - getLogger().info( - `[DiffAnimationController] Large change region detected, limiting animation to ${maxAnimationLines} lines` - ) + /** + * Show static GitHub-style diff view for a file + */ + public async showStaticDiffView(filePath: string): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation) { + getLogger().warn(`[DiffAnimationController] No animation data found for: ${filePath}`) + return } - const totalAnimationLines = Math.max(1, animationEndLine - animationStartLine + 1) + // Open diff view again (static, no animation) + const fileName = filePath.split(/[\\\/]/).pop() || 'file' + const leftUri = vscode.Uri.parse(`${diffViewUriScheme}:${fileName}`).with({ + query: Buffer.from(animation.originalContent).toString('base64'), + }) - // Adjust animation speed based on number of lines - const animationSpeed = totalAnimationLines > 50 ? 20 : 30 // Faster for large changes + // Set content for left side + this.contentProvider.setContent(leftUri.toString(), animation.originalContent) - for (let i = 0; i < totalAnimationLines; i++) { - if (animation.animationCancelled) { - getLogger().info(`[DiffAnimationController] Animation cancelled for: ${filePath}`) - return - } + // Right side is the actual file with final content + const rightUri = vscode.Uri.file(filePath) + + // Ensure file has the final content + const doc = await vscode.workspace.openTextDocument(rightUri) + if (doc.getText() !== animation.newContent) { + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + 0, + 0, + doc.lineCount > 0 ? doc.lineCount - 1 : 0, + doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 + ) + edit.replace(rightUri, fullRange, animation.newContent) + await vscode.workspace.applyEdit(edit) + } - const currentLine = animationStartLine + i + const title = `${fileName}: Original ↔ AI's Changes` - const timeout = setTimeout(async () => { - if (animation.animationCancelled) { - return - } + // Execute diff command + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { + preview: false, + preserveFocus: false, + }) - // Update decorations for streaming effect - await this.updateStreamingDecorationsForRegion( - filePath, - editor, - currentLine, - animationStartLine, - animationEndLine - ) - }, i * animationSpeed) + // Wait for diff view to open + await new Promise((resolve) => setTimeout(resolve, 100)) - timeouts.push(timeout) + // Find the editor + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (!editor) { + getLogger().warn(`[DiffAnimationController] No editor found for static diff view`) + return } - // Final cleanup after animation - const finalTimeout = setTimeout( - async () => { - if (!animation.animationCancelled) { - await this.finalizeAnimation(filePath, editor) - } - }, - totalAnimationLines * animationSpeed + 100 - ) + // Apply GitHub-style diff decorations immediately (no animation) + await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) + + animation.isShowingStaticDiff = true - timeouts.push(finalTimeout) + getLogger().info(`[DiffAnimationController] Showing static diff view for: ${filePath}`) } /** - * Build diff view content from changes + * Exit diff view and restore final content */ - private buildDiffViewContent(changes: Change[]): string { - let diffViewContent = '' + public async exitDiffView(filePath: string): Promise { + const animation = this.activeAnimations.get(filePath) + if (!animation || !animation.isShowingStaticDiff) { + return + } - for (const change of changes) { - const lines = change.value.split('\n').filter((line) => line !== '') - for (const line of lines) { - diffViewContent += line + '\n' - } + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + if (!editor) { + return } - return diffViewContent.trimEnd() + // Clear all decorations + this.additionControllers.get(filePath)?.clear() + this.deletionControllers.get(filePath)?.clear() + + // Restore the final content (without deleted lines) + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + editor.document.positionAt(0), + editor.document.positionAt(editor.document.getText().length) + ) + edit.replace(editor.document.uri, fullRange, animation.newContent) + await vscode.workspace.applyEdit(edit) + + animation.isShowingStaticDiff = false + animation.diffViewContent = undefined + + getLogger().info(`[DiffAnimationController] Exited diff view for: ${filePath}`) } /** - * Calculate change regions for efficient animation + * Apply GitHub-style diff decorations with actual deletion lines */ - private calculateChangeRegions( + private async applyGitHubDiffDecorations( + filePath: string, + editor: vscode.TextEditor, originalContent: string, newContent: string - ): { - firstChangedLine: number - lastChangedLine: number - changes: Change[] - } { + ): Promise { + let additionController = this.additionControllers.get(filePath) + let deletionController = this.deletionControllers.get(filePath) + + if (!additionController) { + additionController = new DecorationController('addition', editor) + this.additionControllers.set(filePath, additionController) + } + + if (!deletionController) { + deletionController = new DecorationController('deletion', editor) + this.deletionControllers.set(filePath, deletionController) + } + + // Calculate diff const changes = diffLines(originalContent, newContent) + const additions: vscode.Range[] = [] + const deletions: vscode.Range[] = [] + let currentLine = 0 - let firstChangedLine = -1 - let lastChangedLine = -1 for (const change of changes) { const lines = change.value.split('\n').filter((line) => line !== '') - if (change.added || change.removed) { - if (firstChangedLine === -1) { - firstChangedLine = currentLine - } - // For removed lines, don't advance currentLine, but track as changed - if (change.removed) { - lastChangedLine = Math.max(lastChangedLine, currentLine) - } else { - lastChangedLine = currentLine + lines.length - 1 + if (change.added) { + // Added lines + for (let i = 0; i < lines.length && currentLine + i < editor.document.lineCount; i++) { + additions.push(new vscode.Range(currentLine + i, 0, currentLine + i, Number.MAX_SAFE_INTEGER)) } - } - - // Only advance line counter for non-removed content - if (!change.removed) { + currentLine += lines.length + } else if (change.removed) { + // Skip removed lines (they're shown in the left panel) + } else { + // Unchanged lines currentLine += lines.length } } - return { - firstChangedLine, - lastChangedLine, - changes, - } - } - - /** - * Update decorations during streaming for a specific region - */ - private async updateStreamingDecorationsForRegion( - filePath: string, - editor: vscode.TextEditor, - currentLine: number, - startLine: number, - endLine: number - ): Promise { - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - const activeLineController = this.activeLineControllers.get(filePath) - const shouldAutoScroll = this.shouldAutoScroll.get(filePath) ?? true - const animation = this.activeAnimations.get(filePath) - - if (!fadedOverlayController || !activeLineController || !animation) { - return - } - - // Clear previous active line - activeLineController.clear() - - // Highlight the current line - activeLineController.setActiveLine(currentLine) - - // Update overlay - only for the animated region - fadedOverlayController.clear() - if (currentLine < endLine) { - const remainingLines = endLine - currentLine - fadedOverlayController.addLines(currentLine + 1, remainingLines) - } + // Apply decorations + additionController.setRanges(additions) + deletionController.setRanges(deletions) - // Smart scrolling - only when needed - if (shouldAutoScroll) { - const visibleRanges = editor.visibleRanges - const isLineVisible = visibleRanges.some( - (range) => currentLine >= range.start.line && currentLine <= range.end.line - ) + getLogger().info( + `[DiffAnimationController] Applied GitHub diff: ${additions.length} additions, ${deletions.length} deletions` + ) - // Only scroll if line is not visible or at edges - if (!isLineVisible || currentLine === startLine || currentLine === endLine) { - this.scrollEditorToLine(editor, currentLine) - } + // Store that we're showing diff view + const animation = this.activeAnimations.get(filePath) + if (animation) { + animation.isShowingStaticDiff = true } } @@ -748,28 +1051,6 @@ export class DiffAnimationController { ) } - /** - * Finalize animation and show diff - */ - private async finalizeAnimation(filePath: string, editor: vscode.TextEditor): Promise { - const animation = this.activeAnimations.get(filePath) - if (!animation || animation.animationCancelled) { - return - } - - // Clear streaming decorations - this.fadedOverlayControllers.get(filePath)?.clear() - this.activeLineControllers.get(filePath)?.clear() - - // Show GitHub-style diff after animation completes - await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) - - // Clear timeouts - this.animationTimeouts.delete(filePath) - - getLogger().info(`[DiffAnimationController] ✅ Animation completed with diff view for: ${filePath}`) - } - /** * Support for incremental diff animation */ @@ -802,7 +1083,7 @@ export class DiffAnimationController { return this.startDiffAnimation(filePath, previousContent, currentContent) } - // Apply content change + // Apply content change using WorkspaceEdit const edit = new vscode.WorkspaceEdit() const fullRange = new vscode.Range( editor.document.positionAt(0), @@ -858,7 +1139,7 @@ export class DiffAnimationController { // If showing diff view, exit it first const animation = this.activeAnimations.get(filePath) if (animation?.isShowingStaticDiff) { - // Restore final content before clearing + // Restore final content before clearing using WorkspaceEdit const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) if (editor && animation.newContent) { const edit = new vscode.WorkspaceEdit() @@ -878,6 +1159,8 @@ export class DiffAnimationController { // Clear all state for this file this.activeAnimations.delete(filePath) + this.fileSnapshots.delete(filePath) + this.hiddenEditors.delete(filePath) const fadedOverlayController = this.fadedOverlayControllers.get(filePath) if (fadedOverlayController) { @@ -935,7 +1218,11 @@ export class DiffAnimationController { */ public isAnimating(filePath: string): boolean { const animation = this.activeAnimations.get(filePath) - return animation ? !animation.isShowingStaticDiff && !animation.animationCancelled : false + const history = this.fileAnimationHistory.get(filePath) + return ( + (animation ? !animation.isShowingStaticDiff && !animation.animationCancelled : false) || + (history ? history.isCurrentlyAnimating : false) + ) } /** @@ -959,6 +1246,12 @@ export class DiffAnimationController { public dispose(): void { getLogger().info('[DiffAnimationController] đŸ’Ĩ Disposing controller') this.stopAllAnimations() + + // Dispose content provider + this.providerDisposable.dispose() + this.contentProvider.dispose() + + // Dispose decoration types fadedOverlayDecorationType.dispose() activeLineDecorationType.dispose() githubAdditionDecorationType.dispose() diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index 23c2d6b56a8..9357c143ac7 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -4,58 +4,74 @@ */ /** - * DiffAnimationHandler - Temporary File Animation Approach + * DiffAnimationHandler - Cline-style Diff View Approach * - * Uses temporary files to show diff animations, with one temp file per source file: - * 1. When file change detected, create or reuse a temporary file - * 2. Show animation in the temporary file (red deletions → green additions) - * 3. Update the actual file with final content - * 4. Keep temp file open for reuse on subsequent changes + * Uses VS Code's built-in diff editor to show animations: + * 1. When file change detected, open diff view (left: original, right: changes) + * 2. Stream content line-by-line with yellow highlight animation + * 3. Show GitHub-style diff decorations after animation completes + * 4. Properly handles new file creation with empty left panel * * Benefits: - * - Deletion animations (red lines) are always visible - * - One temp file per source file - reused for multiple animations - * - Clear separation between animation and actual file - * - No race conditions or timing issues + * - Deletion animations (red lines) are visible in diff view + * - Side-by-side comparison shows exactly what's changing + * - Uses VS Code's native diff viewer + * - No temp file management needed */ import * as vscode from 'vscode' import * as path from 'path' -import * as os from 'os' import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' import { getLogger } from 'aws-core-vscode/shared' -import { DiffAnimationController } from './diffAnimationController' +import { DiffAnimationController, PartialUpdateOptions } from './diffAnimationController' interface PendingFileWrite { filePath: string originalContent: string toolUseId: string timestamp: number + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } +} + +interface QueuedAnimation { + originalContent: string + newContent: string + toolUseId: string + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } } export class DiffAnimationHandler implements vscode.Disposable { /** * BEHAVIOR SUMMARY: * - * 1. ONE TEMP FILE PER SOURCE FILE - * - Each source file gets exactly ONE temporary file - * - The temp file is reused for all subsequent changes - * - Example: "index.js" → "[DIFF] index.js" (always the same temp file) + * 1. DIFF VIEW APPROACH + * - Each file modification opens a diff view + * - Left panel shows original content (read-only) + * - Right panel shows changes with streaming animation * - * 2. TEMP FILES AUTOMATICALLY OPEN - * - When a file is about to be modified, its temp file opens automatically - * - Temp files appear in the second column (side-by-side view) - * - Files stay open for future animations + * 2. AUTOMATIC DIFF VIEW OPENING + * - When a file is about to be modified, capture original content + * - When change is detected, open diff view automatically + * - Files are animated with line-by-line streaming * * 3. ANIMATION FLOW * - Detect change in source file - * - Find or create temp file for that source - * - Replace temp file content with original - * - Run animation (red deletions → green additions) - * - Return focus to source file - * - Keep temp file open for next time + * - Open VS Code diff view + * - Stream content line by line with yellow highlight + * - Apply GitHub-style diff decorations + * - Keep diff view open for review * - * This ensures deletion animations always show properly! + * This ensures deletion animations always show properly in the diff view! */ private diffAnimationController: DiffAnimationController @@ -69,13 +85,11 @@ export class DiffAnimationHandler implements vscode.Disposable { private processedMessages = new Set() // File system watcher private fileWatcher: vscode.FileSystemWatcher | undefined - // Track temporary files for cleanup - maps original file path to temp file path - private tempFileMapping = new Map() - // Track open temp file editors - maps temp file path to editor - private tempFileEditors = new Map() + // Animation queue for handling multiple changes + private animationQueue = new Map() constructor() { - getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler`) + getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler with Cline-style diff view`) this.diffAnimationController = new DiffAnimationController() // Set up file system watcher for all files @@ -83,19 +97,11 @@ export class DiffAnimationHandler implements vscode.Disposable { // Watch for file changes this.fileWatcher.onDidChange(async (uri) => { - // Skip temporary files - if (this.isTempFile(uri.fsPath)) { - return - } await this.handleFileChange(uri) }) // Watch for file creation this.fileWatcher.onDidCreate(async (uri) => { - // Skip temporary files - if (this.isTempFile(uri.fsPath)) { - return - } await this.handleFileChange(uri) }) @@ -107,11 +113,6 @@ export class DiffAnimationHandler implements vscode.Disposable { return } - // Skip temporary files - if (this.isTempFile(event.document.uri.fsPath)) { - return - } - // Skip if we're currently animating this file if (this.animatingFiles.has(event.document.uri.fsPath)) { return @@ -123,96 +124,6 @@ export class DiffAnimationHandler implements vscode.Disposable { } }) this.disposables.push(changeTextDocumentDisposable) - - // Listen for editor close events to clean up temp file references - const onDidCloseTextDocument = vscode.workspace.onDidCloseTextDocument((document) => { - const filePath = document.uri.fsPath - if (this.isTempFile(filePath)) { - // Remove from editor tracking - this.tempFileEditors.delete(filePath) - getLogger().info(`[DiffAnimationHandler] 📄 Temp file editor closed: ${filePath}`) - } - }) - this.disposables.push(onDidCloseTextDocument) - } - - /** - * Check if a file path is a temporary file - */ - private isTempFile(filePath: string): boolean { - // Check if this path is in our temp file mappings - for (const tempPath of this.tempFileMapping.values()) { - if (filePath === tempPath) { - return true - } - } - return false - } - - /** - * Focus on the temp file for a specific source file (if it exists) - */ - public async focusTempFile(sourceFilePath: string): Promise { - const tempFilePath = this.tempFileMapping.get(sourceFilePath) - if (!tempFilePath) { - return false - } - - const editor = this.tempFileEditors.get(tempFilePath) - if (editor && !editor.document.isClosed) { - await vscode.window.showTextDocument(editor.document, { - preview: false, - preserveFocus: false, - }) - getLogger().info(`[DiffAnimationHandler] đŸ‘ī¸ Focused on temp file for: ${sourceFilePath}`) - return true - } - - return false - } - - /** - * Get information about active temp files (for debugging) - */ - public getTempFileInfo(): { sourceFile: string; tempFile: string; isOpen: boolean }[] { - const info: { sourceFile: string; tempFile: string; isOpen: boolean }[] = [] - - for (const [sourceFile, tempFile] of this.tempFileMapping) { - const editor = this.tempFileEditors.get(tempFile) - info.push({ - sourceFile, - tempFile, - isOpen: editor ? !editor.document.isClosed : false, - }) - } - - return info - } - - /** - * Close temp file for a specific source file - */ - public async closeTempFileForSource(sourceFilePath: string): Promise { - const tempFilePath = this.tempFileMapping.get(sourceFilePath) - if (!tempFilePath) { - return - } - - const editor = this.tempFileEditors.get(tempFilePath) - if (editor && !editor.document.isClosed) { - // We can't programmatically close the editor, but we can clean up our references - this.tempFileEditors.delete(tempFilePath) - getLogger().info(`[DiffAnimationHandler] 🧹 Cleaned up temp file references for: ${sourceFilePath}`) - } - - // Delete the temp file - try { - await vscode.workspace.fs.delete(vscode.Uri.file(tempFilePath)) - this.tempFileMapping.delete(sourceFilePath) - getLogger().info(`[DiffAnimationHandler] đŸ—‘ī¸ Deleted temp file: ${tempFilePath}`) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to delete temp file: ${error}`) - } } /** @@ -220,27 +131,24 @@ export class DiffAnimationHandler implements vscode.Disposable { */ public async testAnimation(): Promise { const originalContent = `function hello() { - console.log("Hello World"); - return true; + console.log("Hello World"); + return true; }` const newContent = `function hello(name) { - console.log(\`Hello \${name}!\`); - console.log("Welcome to the app"); - return { success: true, name: name }; + console.log(\`Hello \${name}!\`); + console.log("Welcome to the app"); + return { success: true, name: name }; }` const testFilePath = path.join( - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.tmpdir(), + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || path.resolve('.'), 'test_animation.js' ) getLogger().info(`[DiffAnimationHandler] đŸ§Ē Running test animation for: ${testFilePath}`) - // First simulate the preparation phase (which opens the temp file) - await this.openOrCreateTempFile(testFilePath, originalContent) - - // Then run the animation - await this.animateFileChangeWithTemp(testFilePath, originalContent, newContent, 'test') + // Run the animation using Cline-style diff view + await this.animateFileChangeWithDiff(testFilePath, originalContent, newContent, 'test') } /** @@ -297,7 +205,10 @@ export class DiffAnimationHandler implements vscode.Disposable { * Process fsWrite preparation - capture content BEFORE file is written */ private async processFsWritePreparation(message: ChatMessage, tabId: string): Promise { - const fileList = message.header?.fileList + // Cast to any to access properties that might not be in the type definition + const messageAny = message as any + + const fileList = messageAny.header?.fileList if (!fileList?.filePaths || fileList.filePaths.length === 0) { return } @@ -357,23 +268,23 @@ export class DiffAnimationHandler implements vscode.Disposable { // Create directory if needed const directory = path.dirname(filePath) await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) - // Create empty file - await vscode.workspace.fs.writeFile(uri, Buffer.from('')) - } - - // Open the document (but keep it in background) - const document = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: true, // Keep focus on current editor - viewColumn: vscode.ViewColumn.One, // Open in first column - }) - // IMPORTANT: Automatically open the corresponding temp file if it exists - // This ensures the user can see the animation without manually opening the temp file - await this.openOrCreateTempFile(filePath, originalContent) + // DON'T create the file yet - let the actual write create it + // This ensures we capture the transition from non-existent to new content + getLogger().info( + `[DiffAnimationHandler] 📁 Directory prepared, file will be created by write operation` + ) + } else { + // Open the document (but keep it in background) + const document = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: true, // Keep focus on current editor + viewColumn: vscode.ViewColumn.One, // Open in first column + }) + } - getLogger().info(`[DiffAnimationHandler] ✅ File opened and ready: ${filePath}`) + getLogger().info(`[DiffAnimationHandler] ✅ File prepared: ${filePath}`) } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Failed to prepare file: ${error}`) // Clean up on error @@ -381,121 +292,12 @@ export class DiffAnimationHandler implements vscode.Disposable { } } - /** - * Open or create the temp file for a source file - */ - private async openOrCreateTempFile(sourceFilePath: string, initialContent: string): Promise { - const tempFilePath = this.getOrCreateTempFilePath(sourceFilePath) - - // Check if we already have an editor open for this temp file - let tempFileEditor = this.tempFileEditors.get(tempFilePath) - - if (tempFileEditor && tempFileEditor.document && !tempFileEditor.document.isClosed) { - // Temp file is already open, just ensure it's visible - getLogger().info(`[DiffAnimationHandler] đŸ‘ī¸ Temp file already open, making it visible`) - await vscode.window.showTextDocument(tempFileEditor.document, { - preview: false, - preserveFocus: true, - viewColumn: vscode.ViewColumn.Two, - }) - } else { - // Need to create/open the temp file - getLogger().info(`[DiffAnimationHandler] 📄 Opening temp file for: ${sourceFilePath}`) - - const tempUri = vscode.Uri.file(tempFilePath) - - try { - // Check if temp file exists - await vscode.workspace.fs.stat(tempUri) - } catch { - // File doesn't exist, create it with initial content - await vscode.workspace.fs.writeFile(tempUri, Buffer.from(initialContent, 'utf8')) - } - - // Ensure we have a two-column layout - await vscode.commands.executeCommand('workbench.action.editorLayoutTwoColumns') - - // Open temp file in editor - const tempDoc = await vscode.workspace.openTextDocument(tempUri) - tempFileEditor = await vscode.window.showTextDocument(tempDoc, { - preview: false, - preserveFocus: true, // Don't steal focus - viewColumn: vscode.ViewColumn.Two, // Show in second column - }) - - // Add a header comment to indicate this is a diff animation file - const header = `// đŸŽŦ DIFF ANIMATION for: ${path.basename(sourceFilePath)}\n// This file shows animations of changes (Red = Deleted, Green = Added)\n// ${'='.repeat(60)}\n\n` - if (!tempDoc.getText().startsWith(header)) { - await tempFileEditor.edit((editBuilder) => { - editBuilder.insert(new vscode.Position(0, 0), header) - }) - await tempDoc.save() - } - - // Store the editor reference - this.tempFileEditors.set(tempFilePath, tempFileEditor) - - // Set the language mode to match the original file - const ext = path.extname(sourceFilePath).substring(1).toLowerCase() - const languageMap: { [key: string]: string } = { - js: 'javascript', - ts: 'typescript', - jsx: 'javascriptreact', - tsx: 'typescriptreact', - py: 'python', - rb: 'ruby', - go: 'go', - rs: 'rust', - java: 'java', - cpp: 'cpp', - c: 'c', - cs: 'csharp', - php: 'php', - swift: 'swift', - kt: 'kotlin', - md: 'markdown', - json: 'json', - xml: 'xml', - yaml: 'yaml', - yml: 'yaml', - html: 'html', - css: 'css', - scss: 'scss', - less: 'less', - sql: 'sql', - sh: 'shellscript', - bash: 'shellscript', - ps1: 'powershell', - r: 'r', - dart: 'dart', - vue: 'vue', - lua: 'lua', - pl: 'perl', - } - const languageId = languageMap[ext] || ext || 'plaintext' - - try { - await vscode.languages.setTextDocumentLanguage(tempDoc, languageId) - getLogger().info(`[DiffAnimationHandler] 🎨 Set language mode to: ${languageId}`) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to set language mode: ${error}`) - } - } - - getLogger().info(`[DiffAnimationHandler] ✅ Temp file is ready and visible`) - } - /** * Handle file changes - this is where we detect the actual write */ private async handleFileChange(uri: vscode.Uri): Promise { const filePath = uri.fsPath - // Skip if we're already animating this file - if (this.animatingFiles.has(filePath)) { - return - } - // Check if we have a pending write for this file const pendingWrite = this.pendingWrites.get(filePath) if (!pendingWrite) { @@ -518,17 +320,28 @@ export class DiffAnimationHandler implements vscode.Disposable { // Check if content actually changed if (pendingWrite.originalContent !== newContent) { getLogger().info( - `[DiffAnimationHandler] đŸŽŦ Content changed, starting animation - ` + + `[DiffAnimationHandler] đŸŽŦ Content changed, checking animation status - ` + `original: ${pendingWrite.originalContent.length} chars, new: ${newContent.length} chars` ) - // Start the animation using temporary file - await this.animateFileChangeWithTemp( - filePath, - pendingWrite.originalContent, - newContent, - pendingWrite.toolUseId - ) + // If already animating, queue the change + if (this.animatingFiles.has(filePath)) { + const queue = this.animationQueue.get(filePath) || [] + queue.push({ + originalContent: pendingWrite.originalContent, + newContent, + toolUseId: pendingWrite.toolUseId, + changeLocation: pendingWrite.changeLocation, + }) + this.animationQueue.set(filePath, queue) + getLogger().info( + `[DiffAnimationHandler] 📋 Queued animation for ${filePath} (queue size: ${queue.length})` + ) + return + } + + // Start animation + await this.startAnimation(filePath, pendingWrite, newContent) } else { getLogger().info(`[DiffAnimationHandler] â„šī¸ No content change for: ${filePath}`) } @@ -538,45 +351,91 @@ export class DiffAnimationHandler implements vscode.Disposable { } /** - * Get or create a temporary file path for the given original file + * Start animation and process queue */ - private getOrCreateTempFilePath(originalPath: string): string { - // Check if we already have a temp file for this original file - const existingTempPath = this.tempFileMapping.get(originalPath) - if (existingTempPath) { - getLogger().info(`[DiffAnimationHandler] 🔄 Reusing existing temp file: ${existingTempPath}`) - return existingTempPath + private async startAnimation(filePath: string, pendingWrite: PendingFileWrite, newContent: string): Promise { + // Check if we have change location for partial update + if (pendingWrite.changeLocation) { + // Use partial animation for targeted changes + await this.animatePartialFileChange( + filePath, + pendingWrite.originalContent, + newContent, + pendingWrite.changeLocation, + pendingWrite.toolUseId + ) + } else { + // Use full file animation + await this.animateFileChangeWithDiff( + filePath, + pendingWrite.originalContent, + newContent, + pendingWrite.toolUseId + ) } - // Create new temp file path - const ext = path.extname(originalPath) - const basename = path.basename(originalPath, ext) - // Use a consistent name for the temp file (no timestamp) so it's easier to identify - const tempName = `[DIFF] ${basename}${ext}` - const tempDir = path.join(os.tmpdir(), 'vscode-diff-animations') + // Process queued animations + await this.processQueuedAnimations(filePath) + } + + /** + * Process queued animations for a file + */ + private async processQueuedAnimations(filePath: string): Promise { + const queue = this.animationQueue.get(filePath) + if (!queue || queue.length === 0) { + return + } - // Ensure temp directory exists - try { - if (!require('fs').existsSync(tempDir)) { - require('fs').mkdirSync(tempDir, { recursive: true }) - } - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] Failed to create temp dir: ${error}`) + const next = queue.shift() + if (!next) { + return } - const tempPath = path.join(tempDir, tempName) + getLogger().info( + `[DiffAnimationHandler] đŸŽ¯ Processing queued animation for ${filePath} (${queue.length} remaining)` + ) + + // Use the current file content as the "original" for the next animation + const currentContent = await this.getCurrentFileContent(filePath) + + await this.startAnimation( + filePath, + { + filePath, + originalContent: currentContent, + toolUseId: next.toolUseId, + timestamp: Date.now(), + changeLocation: next.changeLocation, + }, + next.newContent + ) + } - // Store the mapping - this.tempFileMapping.set(originalPath, tempPath) - getLogger().info(`[DiffAnimationHandler] 📄 Created new temp file mapping: ${originalPath} → ${tempPath}`) + /** + * Get current file content + */ + private async getCurrentFileContent(filePath: string): Promise { + try { + const uri = vscode.Uri.file(filePath) + const content = await vscode.workspace.fs.readFile(uri) + return Buffer.from(content).toString('utf8') + } catch { + return '' + } + } - return tempPath + /** + * Check if we should show static diff for a file + */ + public shouldShowStaticDiff(filePath: string, content: string): boolean { + return this.diffAnimationController.shouldShowStaticDiff(filePath, content) } /** - * Animate file changes using a temporary file + * Animate file changes using Cline-style diff view */ - private async animateFileChangeWithTemp( + private async animateFileChangeWithDiff( filePath: string, originalContent: string, newContent: string, @@ -590,204 +449,73 @@ export class DiffAnimationHandler implements vscode.Disposable { this.animatingFiles.add(filePath) const animationId = `${path.basename(filePath)}_${Date.now()}` - // Get or create temporary file path - const tempFilePath = this.getOrCreateTempFilePath(filePath) - - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting animation ${animationId}`) - getLogger().info(`[DiffAnimationHandler] 📄 Using temporary file: ${tempFilePath}`) - - let tempFileEditor: vscode.TextEditor | undefined + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting Cline-style diff animation ${animationId}`) + getLogger().info( + `[DiffAnimationHandler] 📊 Animation details: from ${originalContent.length} chars to ${newContent.length} chars` + ) try { - // Check if we already have an editor open for this temp file - tempFileEditor = this.tempFileEditors.get(tempFilePath) - - if (tempFileEditor && tempFileEditor.document && !tempFileEditor.document.isClosed) { - // Reuse existing editor - getLogger().info(`[DiffAnimationHandler] â™ģī¸ Reusing existing temp file editor`) - - // Make sure it's visible and focused for the animation - tempFileEditor = await vscode.window.showTextDocument(tempFileEditor.document, { - preview: false, - preserveFocus: false, // Take focus for animation - viewColumn: tempFileEditor.viewColumn || vscode.ViewColumn.Two, - }) - - // Replace content with original content for this animation - await tempFileEditor.edit((editBuilder) => { - const fullRange = new vscode.Range( - tempFileEditor!.document.positionAt(0), - tempFileEditor!.document.positionAt(tempFileEditor!.document.getText().length) - ) - editBuilder.replace(fullRange, originalContent) - }) - - await tempFileEditor.document.save() - } else { - // Create new temp file or open existing one - const tempUri = vscode.Uri.file(tempFilePath) - - // Write original content to temp file - getLogger().info( - `[DiffAnimationHandler] 📝 Writing original content to temp file (${originalContent.length} chars)` - ) - await vscode.workspace.fs.writeFile(tempUri, Buffer.from(originalContent, 'utf8')) - - // Open temp file in editor - let tempDoc = await vscode.workspace.openTextDocument(tempUri) + // Show a status message + vscode.window.setStatusBarMessage(`đŸŽŦ Showing changes for ${path.basename(filePath)}...`, 5000) - // Ensure the temp document has the correct content - if (tempDoc.getText() !== originalContent) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Temp file content mismatch, rewriting...`) - await vscode.workspace.fs.writeFile(tempUri, Buffer.from(originalContent, 'utf8')) - tempDoc = await vscode.workspace.openTextDocument(tempUri) - } + // Use the DiffAnimationController with Cline-style diff view + await this.diffAnimationController.startDiffAnimation(filePath, originalContent, newContent, false) - // Show the temp file in a new editor - tempFileEditor = await vscode.window.showTextDocument(tempDoc, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Two, // Show in second column - }) - - // Store the editor reference - this.tempFileEditors.set(tempFilePath, tempFileEditor) - - // Set the language mode to match the original file for proper syntax highlighting - const ext = path.extname(filePath).substring(1).toLowerCase() - const languageMap: { [key: string]: string } = { - js: 'javascript', - ts: 'typescript', - jsx: 'javascriptreact', - tsx: 'typescriptreact', - py: 'python', - rb: 'ruby', - go: 'go', - rs: 'rust', - java: 'java', - cpp: 'cpp', - c: 'c', - cs: 'csharp', - php: 'php', - swift: 'swift', - kt: 'kotlin', - md: 'markdown', - json: 'json', - xml: 'xml', - yaml: 'yaml', - yml: 'yaml', - html: 'html', - css: 'css', - scss: 'scss', - less: 'less', - sql: 'sql', - sh: 'shellscript', - bash: 'shellscript', - ps1: 'powershell', - r: 'r', - dart: 'dart', - vue: 'vue', - lua: 'lua', - pl: 'perl', - } - const languageId = languageMap[ext] || ext || 'plaintext' + getLogger().info(`[DiffAnimationHandler] ✅ Animation started successfully`) - try { - await vscode.languages.setTextDocumentLanguage(tempDoc, languageId) - getLogger().info(`[DiffAnimationHandler] 🎨 Set language mode to: ${languageId}`) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Failed to set language mode: ${error}`) - } - } - - // Wait for editor to be ready - await new Promise((resolve) => setTimeout(resolve, 300)) + // Show completion message + vscode.window.setStatusBarMessage(`✅ Showing changes for ${path.basename(filePath)}`, 3000) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate ${animationId}: ${error}`) + } finally { + this.animatingFiles.delete(filePath) + getLogger().info(`[DiffAnimationHandler] 🏁 Animation ${animationId} completed`) + } + } - // Verify the editor is showing our temp file - if (vscode.window.activeTextEditor?.document.uri.fsPath !== tempFilePath) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Active editor is not showing temp file, refocusing...`) - tempFileEditor = await vscode.window.showTextDocument(tempFileEditor.document, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Active, - }) - await new Promise((resolve) => setTimeout(resolve, 200)) - } + /** + * Animate only the changed portion of the file + */ + private async animatePartialFileChange( + filePath: string, + originalContent: string, + newContent: string, + changeLocation: { startLine: number; endLine: number }, + toolUseId: string + ): Promise { + if (this.animatingFiles.has(filePath)) { + getLogger().info(`[DiffAnimationHandler] â­ī¸ Already animating: ${filePath}`) + return + } - // Double-check the document content before animation - const currentContent = tempFileEditor.document.getText() - if (currentContent !== originalContent) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Document content changed, restoring original content`) - await tempFileEditor.edit((editBuilder) => { - const fullRange = new vscode.Range( - tempFileEditor!.document.positionAt(0), - tempFileEditor!.document.positionAt(currentContent.length) - ) - editBuilder.replace(fullRange, originalContent) - }) - await tempFileEditor.document.save() - await new Promise((resolve) => setTimeout(resolve, 100)) - } + this.animatingFiles.add(filePath) + const animationId = `${path.basename(filePath)}_partial_${Date.now()}` - getLogger().info(`[DiffAnimationHandler] 🎨 Starting diff animation on temp file`) - getLogger().info( - `[DiffAnimationHandler] 📊 Animation details: from ${originalContent.length} chars to ${newContent.length} chars` - ) + getLogger().info( + `[DiffAnimationHandler] đŸŽŦ Starting partial diff animation ${animationId} at lines ${changeLocation.startLine}-${changeLocation.endLine}` + ) + try { // Show a status message - vscode.window.setStatusBarMessage(`đŸŽŦ Animating changes for ${path.basename(filePath)}...`, 5000) - - // Ensure the temp file editor is still active - if (vscode.window.activeTextEditor !== tempFileEditor) { - await vscode.window.showTextDocument(tempFileEditor.document, { - preview: false, - preserveFocus: false, - }) - } - - // Run animation on temp file - try { - await this.diffAnimationController.startDiffAnimation(tempFilePath, originalContent, newContent) - getLogger().info(`[DiffAnimationHandler] ✅ Animation completed successfully`) - } catch (animError) { - getLogger().error(`[DiffAnimationHandler] ❌ Animation failed: ${animError}`) - // Try alternative approach: direct file write - getLogger().info(`[DiffAnimationHandler] 🔄 Attempting fallback animation approach`) - await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), Buffer.from(newContent, 'utf8')) - throw animError - } + vscode.window.setStatusBarMessage( + `đŸŽŦ Showing changes for ${path.basename(filePath)} (lines ${changeLocation.startLine}-${changeLocation.endLine})...`, + 5000 + ) - // IMPORTANT: We keep the temp file open after animation! - // This allows us to reuse it for subsequent changes to the same file. - // The temp file will show all animations for a specific source file. - // Benefits: - // - One temp file per source file (not multiple) - // - User can see the history of changes - // - Better performance (no need to create new files) - // - Clear visual separation from actual file - // - Automatically opens when file is being modified + // Use the enhanced partial update method + await this.diffAnimationController.startPartialDiffAnimation(filePath, originalContent, newContent, { + changeLocation, + isPartialUpdate: true, + } as PartialUpdateOptions) - // Keep temp file open after animation (don't close it) - // The user can close it manually or it will be reused for next animation - getLogger().info(`[DiffAnimationHandler] 📌 Keeping temp file open for potential reuse`) + getLogger().info(`[DiffAnimationHandler] ✅ Partial animation completed successfully`) // Show completion message - vscode.window.setStatusBarMessage(`✅ Animation completed for ${path.basename(filePath)}`, 3000) - - // Focus back on the original file - const originalUri = vscode.Uri.file(filePath) - try { - const originalDoc = await vscode.workspace.openTextDocument(originalUri) - await vscode.window.showTextDocument(originalDoc, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.One, - }) - } catch (error) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not focus original file: ${error}`) - } + vscode.window.setStatusBarMessage(`✅ Updated ${path.basename(filePath)}`, 3000) } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate ${animationId}: ${error}`) + // Fall back to full animation + await this.animateFileChangeWithDiff(filePath, originalContent, newContent, toolUseId) } finally { this.animatingFiles.delete(filePath) getLogger().info(`[DiffAnimationHandler] 🏁 Animation ${animationId} completed`) @@ -801,6 +529,7 @@ export class DiffAnimationHandler implements vscode.Disposable { originalFileUri: string originalFileContent?: string fileContent?: string + isFromChatClick?: boolean }): Promise { getLogger().info(`[DiffAnimationHandler] 🎨 Processing file diff for: ${params.originalFileUri}`) @@ -814,11 +543,18 @@ export class DiffAnimationHandler implements vscode.Disposable { const originalContent = params.originalFileContent || '' const newContent = params.fileContent || '' - if (originalContent !== newContent) { - getLogger().info(`[DiffAnimationHandler] ✨ Content differs, starting diff animation`) + if (originalContent !== newContent || !params.isFromChatClick) { + getLogger().info( + `[DiffAnimationHandler] ✨ Content differs or not from chat click, starting diff animation` + ) - // Use temp file approach for this too - await this.animateFileChangeWithTemp(filePath, originalContent, newContent, 'manual_diff') + // Pass the isFromChatClick flag to the controller + await this.diffAnimationController.startDiffAnimation( + filePath, + originalContent, + newContent, + params.isFromChatClick || false + ) } else { getLogger().warn(`[DiffAnimationHandler] âš ī¸ Original and new content are identical`) } @@ -827,6 +563,23 @@ export class DiffAnimationHandler implements vscode.Disposable { } } + /** + * Show static diff view for a file (when clicked from chat) + */ + public async showStaticDiffForFile(filePath: string): Promise { + getLogger().info(`[DiffAnimationHandler] 👆 File clicked from chat: ${filePath}`) + + // Normalize the file path + const normalizedPath = await this.normalizeFilePath(filePath) + if (!normalizedPath) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${filePath}`) + return + } + + // Show static diff view without animation + await this.diffAnimationController.showStaticDiffView(normalizedPath) + } + /** * Process ChatUpdateParams */ @@ -947,14 +700,6 @@ export class DiffAnimationHandler implements vscode.Disposable { getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${oldSize} processed messages`) } - // Clean up closed temp file editors - for (const [tempPath, editor] of this.tempFileEditors) { - if (!editor || editor.document.isClosed) { - this.tempFileEditors.delete(tempPath) - getLogger().info(`[DiffAnimationHandler] 🧹 Removed closed temp file editor: ${tempPath}`) - } - } - if (cleanedWrites > 0) { getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${cleanedWrites} old pending writes`) } @@ -963,34 +708,11 @@ export class DiffAnimationHandler implements vscode.Disposable { public async dispose(): Promise { getLogger().info(`[DiffAnimationHandler] đŸ’Ĩ Disposing DiffAnimationHandler`) - // Close all temp file editors - for (const [tempPath, editor] of this.tempFileEditors) { - try { - if (editor && !editor.document.isClosed) { - getLogger().info(`[DiffAnimationHandler] 📄 Closing temp file editor: ${tempPath}`) - // Note: We can't programmatically close editors, but we can clean up our references - } - } catch (error) { - // Ignore errors during cleanup - } - } - - // Clean up any remaining temp files - for (const tempPath of this.tempFileMapping.values()) { - try { - await vscode.workspace.fs.delete(vscode.Uri.file(tempPath)) - getLogger().info(`[DiffAnimationHandler] đŸ—‘ī¸ Deleted temp file: ${tempPath}`) - } catch (error) { - // Ignore errors during cleanup - } - } - // Clear all tracking sets and maps this.pendingWrites.clear() this.processedMessages.clear() this.animatingFiles.clear() - this.tempFileMapping.clear() - this.tempFileEditors.clear() + this.animationQueue.clear() // Dispose the diff animation controller this.diffAnimationController.dispose() diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index e125d485a72..954c3f43af2 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -513,12 +513,29 @@ export function registerMessageListeners( languageClient.onNotification(openFileDiffNotificationType.method, async (params: OpenFileDiffParams) => { // Try to use DiffAnimationHandler first try { - await animationHandler.processFileDiff({ - originalFileUri: params.originalFileUri, - originalFileContent: params.originalFileContent, - fileContent: params.fileContent, - }) - getLogger().info('[VSCode Client] Successfully triggered diff animation') + // Normalize the file path + const normalizedPath = params.originalFileUri.startsWith('file://') + ? vscode.Uri.parse(params.originalFileUri).fsPath + : params.originalFileUri + + const newContent = params.fileContent || '' + + getLogger().info(`[VSCode Client] OpenFileDiff notification for: ${normalizedPath}`) + + // Check if we should show static diff (same content as last animation) + if (animationHandler.shouldShowStaticDiff(normalizedPath, newContent)) { + getLogger().info('[VSCode Client] Same content as last animation, showing static diff') + await animationHandler.showStaticDiffForFile(normalizedPath) + } else { + getLogger().info('[VSCode Client] New content detected, starting animation') + // This is from chat click, pass the flag + await animationHandler.processFileDiff({ + originalFileUri: params.originalFileUri, + originalFileContent: params.originalFileContent, + fileContent: params.fileContent, + isFromChatClick: true, + }) + } } catch (error) { // If animation fails, fall back to the original diff view getLogger().error(`[VSCode Client] Diff animation failed, falling back to standard diff view: ${error}`) From ddb89f02eb2d1b0dd519b680f98522c2d1ab0cc6 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Sun, 15 Jun 2025 14:51:37 -0700 Subject: [PATCH 13/32] Display diff animation progressively --- .../diffAnimation/diffAnimationController.ts | 1723 ++++++++--------- .../diffAnimation/diffAnimationHandler.ts | 51 +- packages/amazonq/src/lsp/chat/messages.ts | 14 +- 3 files changed, 823 insertions(+), 965 deletions(-) diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index 768a0596d4c..a147f450ad0 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -4,195 +4,28 @@ */ /** - * DiffAnimationController - Provides Cline-style streaming animations with GitHub diff visualization + * DiffAnimationController - Progressive Diff Animation with Smart Scanning * - * Key Optimizations: - * 1. Region-based animation: Only animates changed lines instead of entire file - * 2. Smart diff calculation: Uses efficient diff algorithm to find change boundaries - * 3. Viewport limiting: Caps animation to 100 lines max for performance - * 4. Context awareness: Includes 3 lines before/after changes for better visibility - * 5. Dynamic speed: Faster animation for larger changes (20ms vs 30ms per line) - * 6. Efficient scrolling: Only scrolls when necessary (line not visible) - * - * Animation Flow: - * - Calculate changed region using diffLines - * - Apply new content immediately - * - Overlay only the changed region - * - Animate line-by-line reveal with yellow highlight - * - Show GitHub-style diff after completion + * Key Features: + * 1. Progressive rendering - lines appear as they are scanned + * 2. Smart region detection - only scans changed areas + context + * 3. Yellow scanning line animation like Cline + * 4. Auto-scroll with user override detection + * 5. GitHub-style diff decorations */ import * as vscode from 'vscode' +import * as path from 'path' import { getLogger } from 'aws-core-vscode/shared' import { diffLines } from 'diff' -const diffViewUriScheme = 'amazon-q-diff' - -// Decoration controller to manage decoration states -class DecorationController { - private decorationType: 'fadedOverlay' | 'activeLine' | 'addition' | 'deletion' | 'deletionMarker' - private editor: vscode.TextEditor - private ranges: vscode.Range[] = [] - - constructor( - decorationType: 'fadedOverlay' | 'activeLine' | 'addition' | 'deletion' | 'deletionMarker', - editor: vscode.TextEditor - ) { - this.decorationType = decorationType - this.editor = editor - } - - getDecoration(): vscode.TextEditorDecorationType { - switch (this.decorationType) { - case 'fadedOverlay': - return fadedOverlayDecorationType - case 'activeLine': - return activeLineDecorationType - case 'addition': - return githubAdditionDecorationType - case 'deletion': - return githubDeletionDecorationType - case 'deletionMarker': - return deletionMarkerDecorationType - } - } - - addLines(startIndex: number, numLines: number): void { - // Guard against invalid inputs - if (startIndex < 0 || numLines <= 0) { - return - } - - const lastRange = this.ranges[this.ranges.length - 1] - if (lastRange && lastRange.end.line === startIndex - 1) { - this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines)) - } else { - const endLine = startIndex + numLines - 1 - this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER)) - } - - this.editor.setDecorations(this.getDecoration(), this.ranges) - } - - clear(): void { - this.ranges = [] - this.editor.setDecorations(this.getDecoration(), this.ranges) - } - - updateOverlayAfterLine(line: number, totalLines: number): void { - // Remove any existing ranges that start at or after the current line - this.ranges = this.ranges.filter((range) => range.end.line < line) - - // Add a new range for all lines after the current line - if (line < totalLines - 1) { - this.ranges.push( - new vscode.Range( - new vscode.Position(line + 1, 0), - new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER) - ) - ) - } - - // Apply the updated decorations - this.editor.setDecorations(this.getDecoration(), this.ranges) - } - - setActiveLine(line: number): void { - this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)] - this.editor.setDecorations(this.getDecoration(), this.ranges) - } - - setRanges(ranges: vscode.Range[]): void { - this.ranges = ranges - this.editor.setDecorations(this.getDecoration(), this.ranges) - } -} - -// Decoration types matching Cline's style -const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 255, 0, 0.1)', - opacity: '0.4', - isWholeLine: true, -}) - -const activeLineDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(255, 255, 0, 0.3)', - opacity: '1', - isWholeLine: true, - border: '1px solid rgba(255, 255, 0, 0.5)', -}) - -// GitHub-style diff decorations -const githubAdditionDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(46, 160, 67, 0.15)', - isWholeLine: true, - before: { - contentText: '+', - color: 'rgb(46, 160, 67)', - fontWeight: 'bold', - width: '20px', - margin: '0 10px 0 0', - }, - overviewRulerColor: 'rgba(46, 160, 67, 0.8)', - overviewRulerLane: vscode.OverviewRulerLane.Right, -}) - -const githubDeletionDecorationType = vscode.window.createTextEditorDecorationType({ - backgroundColor: 'rgba(248, 81, 73, 0.15)', - isWholeLine: true, - textDecoration: 'line-through', - opacity: '0.7', - before: { - contentText: '-', - color: 'rgb(248, 81, 73)', - fontWeight: 'bold', - width: '20px', - margin: '0 10px 0 0', - }, - overviewRulerColor: 'rgba(248, 81, 73, 0.8)', - overviewRulerLane: vscode.OverviewRulerLane.Right, -}) - -// Decoration for showing deletion markers -const deletionMarkerDecorationType = vscode.window.createTextEditorDecorationType({ - after: { - contentText: ' ← line(s) removed', - color: 'rgba(248, 81, 73, 0.7)', - fontStyle: 'italic', - margin: '0 0 0 20px', - }, - overviewRulerColor: 'rgba(248, 81, 73, 0.8)', - overviewRulerLane: vscode.OverviewRulerLane.Left, -}) - -// Content provider for the left side of diff view -class DiffContentProvider implements vscode.TextDocumentContentProvider { - private content = new Map() - private _onDidChange = new vscode.EventEmitter() - readonly onDidChange = this._onDidChange.event - - setContent(uri: string, content: string): void { - this.content.set(uri, content) - this._onDidChange.fire(vscode.Uri.parse(uri)) - } - - provideTextDocumentContent(uri: vscode.Uri): string { - return this.content.get(uri.toString()) || '' - } - - dispose(): void { - this._onDidChange.dispose() - } -} - export interface DiffAnimation { uri: vscode.Uri originalContent: string newContent: string isShowingStaticDiff?: boolean animationCancelled?: boolean - diffViewContent?: string // Store the diff view content - isFromChatClick?: boolean // Add this to track if opened from chat + isFromChatClick?: boolean } export interface PartialUpdateOptions { @@ -202,26 +35,21 @@ export interface PartialUpdateOptions { startChar?: number endChar?: number } - searchContent?: string // The content being searched for - isPartialUpdate?: boolean // Whether this is a partial update vs full file + searchContent?: string + isPartialUpdate?: boolean +} + +interface DiffLine { + type: 'unchanged' | 'added' | 'removed' + content: string + lineNumber: number + oldLineNumber?: number + newLineNumber?: number } export class DiffAnimationController { private activeAnimations = new Map() - private fadedOverlayControllers = new Map() - private activeLineControllers = new Map() - private additionControllers = new Map() - private deletionControllers = new Map() - private deletionMarkerControllers = new Map() - private streamedLines = new Map() - private lastFirstVisibleLine = new Map() - private shouldAutoScroll = new Map() - private scrollListeners = new Map() - private animationTimeouts = new Map() - private fileSnapshots = new Map() // Store file content before animation - private hiddenEditors = new Map() // Store hidden editors for undo preservation - - // Track file animation history for intelligent diff display + private diffWebviews = new Map() private fileAnimationHistory = new Map< string, { @@ -230,20 +58,19 @@ export class DiffAnimationController { isCurrentlyAnimating: boolean } >() + private animationTimeouts = new Map() + private fileSnapshots = new Map() - // Content provider for diff view - private contentProvider: DiffContentProvider - private providerDisposable: vscode.Disposable + // Auto-scroll control + private shouldAutoScroll = new Map() + private lastScrollPosition = new Map() constructor() { - getLogger().info('[DiffAnimationController] 🚀 Initialized with Cline-style streaming and GitHub diff support') + getLogger().info('[DiffAnimationController] 🚀 Initialized with progressive scanning animation') + } - // Initialize content provider for diff view - this.contentProvider = new DiffContentProvider() - this.providerDisposable = vscode.workspace.registerTextDocumentContentProvider( - diffViewUriScheme, - this.contentProvider - ) + public getAnimationData(filePath: string): DiffAnimation | undefined { + return this.activeAnimations.get(filePath) } /** @@ -252,16 +79,14 @@ export class DiffAnimationController { public shouldShowStaticDiff(filePath: string, newContent: string): boolean { const history = this.fileAnimationHistory.get(filePath) if (!history) { - return false // Never animated before + return false } - // If currently animating, don't show static diff if (history.isCurrentlyAnimating) { return false } - // If content is the same as last animated content, show static diff - return history.lastAnimatedContent === newContent + return true } /** @@ -291,184 +116,6 @@ export class DiffAnimationController { } } - /** - * Start a diff animation for a file using Cline's streaming approach - */ - /** - * Start a diff animation for a file using Cline's streaming approach - */ - public async startDiffAnimation( - filePath: string, - originalContent: string, - newContent: string, - isFromChatClick: boolean = false - ): Promise { - const isNewFile = originalContent === '' - getLogger().info( - `[DiffAnimationController] đŸŽŦ Starting animation for: ${filePath} (new file: ${isNewFile}, from chat: ${isFromChatClick})` - ) - - // Check if we should show static diff instead - if (isFromChatClick && this.shouldShowStaticDiff(filePath, newContent)) { - getLogger().info(`[DiffAnimationController] Content unchanged, showing static diff`) - await this.showStaticDiffView(filePath) - return - } - - try { - // Cancel any existing animation for this file - this.cancelAnimation(filePath) - - // Mark animation as started - this.updateAnimationStart(filePath) - - const uri = vscode.Uri.file(filePath) - - // Store animation state - const animation: DiffAnimation = { - uri, - originalContent, - newContent, - isShowingStaticDiff: false, - animationCancelled: false, - isFromChatClick, - } - this.activeAnimations.set(filePath, animation) - - // Ensure the file exists and apply the new content - let doc: vscode.TextDocument - - try { - // Try to open existing file - doc = await vscode.workspace.openTextDocument(uri) - // Store current content as snapshot - this.fileSnapshots.set(filePath, doc.getText()) - } catch { - // File doesn't exist, create it with empty content first - await vscode.workspace.fs.writeFile(uri, Buffer.from('')) - doc = await vscode.workspace.openTextDocument(uri) - this.fileSnapshots.set(filePath, '') - getLogger().info(`[DiffAnimationController] Created new file: ${filePath}`) - } - - // Apply the new content using WorkspaceEdit (this preserves undo history) - // Do this WITHOUT opening a visible editor - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - 0, - 0, - doc.lineCount > 0 ? doc.lineCount - 1 : 0, - doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 - ) - edit.replace(uri, fullRange, newContent) - - // Apply edit with undo support - const success = await vscode.workspace.applyEdit(edit) - if (!success) { - throw new Error('Failed to apply edit to file') - } - - // Save the document to ensure changes are persisted - await doc.save() - - // Now open the diff view for animation - const diffEditor = await this.openClineDiffView(filePath, originalContent, isNewFile) - if (!diffEditor) { - throw new Error('Failed to open diff view') - } - - // Initialize controllers - const fadedOverlayController = new DecorationController('fadedOverlay', diffEditor) - const activeLineController = new DecorationController('activeLine', diffEditor) - - this.fadedOverlayControllers.set(filePath, fadedOverlayController) - this.activeLineControllers.set(filePath, activeLineController) - - // Initialize state - this.streamedLines.set(filePath, []) - this.shouldAutoScroll.set(filePath, true) - this.lastFirstVisibleLine.set(filePath, 0) - - // Add scroll detection - const scrollListener = vscode.window.onDidChangeTextEditorVisibleRanges((e) => { - if (e.textEditor === diffEditor) { - const currentFirstVisibleLine = e.visibleRanges[0]?.start.line || 0 - const lastLine = this.lastFirstVisibleLine.get(filePath) || 0 - - // If user scrolled up, disable auto-scroll - if (currentFirstVisibleLine < lastLine) { - this.shouldAutoScroll.set(filePath, false) - } - - this.lastFirstVisibleLine.set(filePath, currentFirstVisibleLine) - } - }) - this.scrollListeners.set(filePath, scrollListener) - - // Calculate changed region for optimization - const changedRegion = this.calculateChangedRegion(originalContent, newContent) - getLogger().info( - `[DiffAnimationController] Changed region: lines ${changedRegion.startLine}-${changedRegion.endLine}` - ) - - // Start streaming animation (Cline style) - visual only - await this.streamContentClineStyle(filePath, diffEditor, newContent, animation, changedRegion) - } catch (error) { - getLogger().error(`[DiffAnimationController] ❌ Failed to start animation: ${error}`) - // Restore file content on error using WorkspaceEdit - const snapshot = this.fileSnapshots.get(filePath) - if (snapshot !== undefined) { - try { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - 0, - 0, - doc.lineCount > 0 ? doc.lineCount - 1 : 0, - doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 - ) - edit.replace(doc.uri, fullRange, snapshot) - await vscode.workspace.applyEdit(edit) - } catch (restoreError) { - getLogger().error(`[DiffAnimationController] Failed to restore content: ${restoreError}`) - } - } - this.stopDiffAnimation(filePath) - throw error - } - } - - /** - * Close the diff view and return to normal file view - */ - private async closeDiffView(filePath: string): Promise { - try { - // Find all visible editors - const editors = vscode.window.visibleTextEditors - - // Find the diff editor (it will have the special URI scheme) - const diffEditor = editors.find( - (e) => e.document.uri.scheme === diffViewUriScheme || e.document.uri.fsPath === filePath - ) - - if (diffEditor) { - // Close the diff view - await vscode.commands.executeCommand('workbench.action.closeActiveEditor') - - // Open the file normally after diff view is closed - const uri = vscode.Uri.file(filePath) - const doc = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(doc, { preview: false }) - - getLogger().info( - `[DiffAnimationController] Closed diff view and opened normal file view for: ${filePath}` - ) - } - } catch (error) { - getLogger().error(`[DiffAnimationController] Error closing diff view: ${error}`) - } - } - /** * Calculate the changed region between original and new content */ @@ -542,591 +189,838 @@ export class DiffAnimationController { } /** - * Start partial diff animation for specific changes + * Start a diff animation for a file */ - public async startPartialDiffAnimation( + public async startDiffAnimation( filePath: string, originalContent: string, newContent: string, - options: PartialUpdateOptions = {} + isFromChatClick: boolean = false ): Promise { - const { changeLocation, searchContent, isPartialUpdate = false } = options - - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting partial animation for: ${filePath}`) - - // If we have a specific change location, we can optimize the animation - if (changeLocation && isPartialUpdate) { - // Check if we already have a diff view open - const existingAnimation = this.activeAnimations.get(filePath) - const existingEditor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - - if (existingAnimation && existingEditor) { - // Update only the changed portion - await this.updatePartialContent( - filePath, - existingEditor, - existingAnimation, - changeLocation, - searchContent || '', - newContent - ) - return - } + const isNewFile = originalContent === '' + getLogger().info( + `[DiffAnimationController] đŸŽŦ Starting animation for: ${filePath} (new file: ${isNewFile}, from chat: ${isFromChatClick})` + ) + + if (isFromChatClick) { + getLogger().info(`[DiffAnimationController] File clicked from chat, showing VS Code diff`) + await this.showVSCodeDiff(filePath, originalContent, newContent) + return } - // Fall back to full animation if no optimization possible - return this.startDiffAnimation(filePath, originalContent, newContent) - } + try { + // Cancel any existing animation for this file + this.cancelAnimation(filePath) - /** - * Cancel ongoing animation for a file - */ - private cancelAnimation(filePath: string): void { - const animation = this.activeAnimations.get(filePath) - if (animation && !animation.isShowingStaticDiff) { - animation.animationCancelled = true + // Mark animation as started + this.updateAnimationStart(filePath) - // Clear any pending timeouts - const timeouts = this.animationTimeouts.get(filePath) - if (timeouts) { - for (const timeout of timeouts) { - clearTimeout(timeout) - } - this.animationTimeouts.delete(filePath) + const uri = vscode.Uri.file(filePath) + + // Store animation state + const animation: DiffAnimation = { + uri, + originalContent, + newContent, + isShowingStaticDiff: false, + animationCancelled: false, + isFromChatClick, } + this.activeAnimations.set(filePath, animation) - // Clear decorations - this.fadedOverlayControllers.get(filePath)?.clear() - this.activeLineControllers.get(filePath)?.clear() + // Ensure the file exists and has the new content + let doc: vscode.TextDocument + try { + doc = await vscode.workspace.openTextDocument(uri) + this.fileSnapshots.set(filePath, doc.getText()) + } catch { + // Create new file + await vscode.workspace.fs.writeFile(uri, Buffer.from('')) + doc = await vscode.workspace.openTextDocument(uri) + this.fileSnapshots.set(filePath, '') + } - getLogger().info(`[DiffAnimationController] âš ī¸ Cancelled ongoing animation for: ${filePath}`) - } - } + // Apply the new content + const edit = new vscode.WorkspaceEdit() + const fullRange = new vscode.Range( + 0, + 0, + doc.lineCount > 0 ? doc.lineCount - 1 : 0, + doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 + ) + edit.replace(uri, fullRange, newContent) + await vscode.workspace.applyEdit(edit) + await doc.save() - /** - * Open VS Code diff view (Cline style) - */ - private async openClineDiffView( - filePath: string, - originalContent: string, - isNewFile: boolean - ): Promise { - const fileName = filePath.split(/[\\\/]/).pop() || 'file' - const leftUri = vscode.Uri.parse(`${diffViewUriScheme}:${fileName}`).with({ - query: Buffer.from(originalContent).toString('base64'), - }) + // Calculate changed region for optimization + const changedRegion = this.calculateChangedRegion(originalContent, newContent) + getLogger().info( + `[DiffAnimationController] Changed region: lines ${changedRegion.startLine}-${changedRegion.endLine}` + ) - // Set content for left side - this.contentProvider.setContent(leftUri.toString(), originalContent) + // Initialize scroll control + this.shouldAutoScroll.set(filePath, true) + this.lastScrollPosition.set(filePath, 0) - // Right side is the actual file - const rightUri = vscode.Uri.file(filePath) + // Create or reuse webview for this file + const webview = await this.getOrCreateDiffWebview(filePath) - // DO NOT clear the right side content - it already has the final content - // This preserves the undo history - // await vscode.workspace.fs.writeFile(rightUri, Buffer.from('')) + // Start the progressive animation + await this.animateDiffInWebview(filePath, webview, originalContent, newContent, animation, changedRegion) + } catch (error) { + getLogger().error(`[DiffAnimationController] ❌ Failed to start animation: ${error}`) + this.stopDiffAnimation(filePath) + throw error + } + } - const title = `${fileName}: ${isNewFile ? 'New File' : "Original ↔ AI's Changes"} (Streaming...)` + /** + * Get or create a webview panel for diff display + */ + private async getOrCreateDiffWebview(filePath: string): Promise { + await vscode.commands.executeCommand('workbench.action.closeAllEditors') + // Check if we already have a webview for this file + let webview = this.diffWebviews.get(filePath) + if (webview) { + // Reveal existing webview + webview.reveal(vscode.ViewColumn.One) + return webview + } - // Execute diff command - await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { - preview: false, - preserveFocus: false, + // Create new webview + const fileName = path.basename(filePath) + webview = vscode.window.createWebviewPanel('amazonQDiff', `Diff: ${fileName}`, vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [], }) - // Wait a bit for the diff view to open - await new Promise((resolve) => setTimeout(resolve, 100)) + // Store webview + this.diffWebviews.set(filePath, webview) + // Handle webview disposal + webview.onDidDispose(() => { + this.diffWebviews.delete(filePath) + this.stopDiffAnimation(filePath) + // Reopen the original file editor after webview is disposed + Promise.resolve( + vscode.workspace.openTextDocument(vscode.Uri.file(filePath)).then((doc) => { + void vscode.window.showTextDocument(doc, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }) + }) + ).catch((error) => { + getLogger().error(`[DiffAnimationController] Failed to reopen file after webview disposal: ${error}`) + }) + }) + // Handle messages from webview (including scroll events) + webview.webview.onDidReceiveMessage((message) => { + if (message.command === 'userScrolled') { + const currentPosition = message.scrollTop + const lastPosition = this.lastScrollPosition.get(filePath) || 0 + + // If user scrolled up, disable auto-scroll + if (currentPosition < lastPosition - 50) { + // 50px threshold + this.shouldAutoScroll.set(filePath, false) + getLogger().info(`[DiffAnimationController] Auto-scroll disabled for: ${filePath}`) + } - // Find the editor for the right side (the actual file) - let editor = vscode.window.activeTextEditor - if (editor && editor.document.uri.fsPath === filePath) { - return editor - } + this.lastScrollPosition.set(filePath, currentPosition) + } + }) - // Fallback: find editor by URI - editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (editor) { - return editor - } + // Set initial HTML + webview.webview.html = this.getDiffWebviewContent() - // Another attempt after a short delay - await new Promise((resolve) => setTimeout(resolve, 100)) - return vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) + return webview } /** - * Stream content line by line (Cline style) with optimization for changed region + * Get the HTML content for the diff webview */ - private async streamContentClineStyle( - filePath: string, - editor: vscode.TextEditor, - newContent: string, - animation: DiffAnimation, - changedRegion: { startLine: number; endLine: number; totalLines: number } - ): Promise { - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - const activeLineController = this.activeLineControllers.get(filePath) - - if (!fadedOverlayController || !activeLineController || animation.animationCancelled) { - return + private getDiffWebviewContent(): string { + return ` + + + + + Diff View + + + +
+
+
Original
+
+
Scanning changes...
+
+
+
+
AI's Changes
+
+
Scanning changes...
+
+
+
+ +
+ Scanning line 0 +
+ + + +` } /** - * Update only a portion of the file + * Animate diff in webview progressively with smart scanning */ - private async updatePartialContent( + private async animateDiffInWebview( filePath: string, - editor: vscode.TextEditor, + webview: vscode.WebviewPanel, + originalContent: string, + newContent: string, animation: DiffAnimation, - changeLocation: { startLine: number; endLine: number }, - searchContent: string, - newContent: string + changedRegion: { startLine: number; endLine: number; totalLines: number } ): Promise { - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - const activeLineController = this.activeLineControllers.get(filePath) + try { + // Parse diff and create scan plan + const { leftLines, rightLines, scanPlan } = this.createScanPlan(originalContent, newContent, changedRegion) - if (!fadedOverlayController || !activeLineController) { - return - } + // Clear and start scan + await webview.webview.postMessage({ command: 'clear' }) + await new Promise((resolve) => setTimeout(resolve, 50)) - getLogger().info( - `[DiffAnimationController] 📝 Partial update at lines ${changeLocation.startLine}-${changeLocation.endLine}` - ) + await webview.webview.postMessage({ + command: 'startScan', + totalLines: scanPlan.length, + }) + + // Pre-add lines that are before the scan region (context) + for (let i = 0; i < Math.min(changedRegion.startLine, 3); i++) { + if (leftLines[i]) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'left', + line: leftLines[i], + immediately: true, + }) + } + if (rightLines[i]) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'right', + line: rightLines[i], + immediately: true, + }) + } + } - // Find the exact location in the current document - const document = editor.document - let matchStartLine = -1 + // Calculate animation speed + const scanDelay = scanPlan.length > 50 ? 40 : 70 - if (searchContent) { - // Search for the exact content in the document - const documentText = document.getText() - const searchIndex = documentText.indexOf(searchContent) + // Execute scan plan + for (const scanItem of scanPlan) { + if (animation.animationCancelled) { + break + } - if (searchIndex !== -1) { - // Convert character index to line number - const textBefore = documentText.substring(0, searchIndex) - matchStartLine = (textBefore.match(/\n/g) || []).length - } - } else { - // Use the provided line number directly - matchStartLine = changeLocation.startLine - } + // Add lines if not already added + if (scanItem.leftLine && !scanItem.preAdded) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'left', + line: scanItem.leftLine, + immediately: false, + }) + } - if (matchStartLine === -1) { - getLogger().warn(`[DiffAnimationController] Could not find search content, falling back to full scan`) - return this.startDiffAnimation(filePath, animation.originalContent, newContent) - } + if (scanItem.rightLine && !scanItem.preAdded) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'right', + line: scanItem.rightLine, + immediately: false, + }) + } - // Calculate the replacement - const searchLines = searchContent.split('\n') - const replacementLines = this.extractReplacementContent(animation.originalContent, newContent, searchContent) - - // Apply the edit using WorkspaceEdit for undo support - const edit = new vscode.WorkspaceEdit() - const startPos = new vscode.Position(matchStartLine, 0) - const endPos = new vscode.Position(matchStartLine + searchLines.length, 0) - const range = new vscode.Range(startPos, endPos) - - edit.replace(editor.document.uri, range, replacementLines.join('\n') + '\n') - await vscode.workspace.applyEdit(edit) - - // Animate only the changed lines - await this.animatePartialChange( - editor, - fadedOverlayController, - activeLineController, - matchStartLine, - replacementLines.length - ) + // Small delay to ensure lines are added + await new Promise((resolve) => setTimeout(resolve, 10)) - // Scroll to the change - if (this.shouldAutoScroll.get(filePath) !== false) { - this.scrollEditorToLine(editor, matchStartLine) - } + // Scan the line + await webview.webview.postMessage({ + command: 'scanLine', + leftIndex: scanItem.leftIndex, + rightIndex: scanItem.rightIndex, + autoScroll: this.shouldAutoScroll.get(filePath) !== false, + }) - // Update animation state - animation.newContent = document.getText() - } + // Wait before next line + await new Promise((resolve) => setTimeout(resolve, scanDelay)) + } - /** - * Extract replacement content - */ - private extractReplacementContent(originalContent: string, newContent: string, searchContent: string): string[] { - // This would use the SEARCH/REPLACE logic to extract just the replacement portion - const newLines = newContent.split('\n') - const searchLines = searchContent.split('\n') - - // Find where the change starts in the new content - let startIndex = 0 - for (let i = 0; i < newLines.length; i++) { - if (newLines.slice(i, i + searchLines.length).join('\n') !== searchContent) { - startIndex = i - break + // Add any remaining lines after scan region + for (let i = changedRegion.endLine + 1; i < leftLines.length || i < rightLines.length; i++) { + if (i < leftLines.length) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'left', + line: leftLines[i], + immediately: true, + }) + } + if (i < rightLines.length) { + await webview.webview.postMessage({ + command: 'addLine', + side: 'right', + line: rightLines[i], + immediately: true, + }) + } } - } - // Extract the replacement lines - return newLines.slice(startIndex, startIndex + searchLines.length) + // Complete animation + await webview.webview.postMessage({ command: 'completeScan' }) + + // Update animation history + this.updateAnimationComplete(filePath, newContent) + + getLogger().info(`[DiffAnimationController] ✅ Smart scanning completed for: ${filePath}`) + + // Auto-close after a delay if not from chat click + // Auto-close after a delay if not from chat click + if (!animation.isFromChatClick) { + setTimeout(async () => { + this.closeDiffWebview(filePath) + + // ADD THIS: Optionally reopen the file in normal editor + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + await vscode.window.showTextDocument(doc, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }) + getLogger().info(`[DiffAnimationController] Reopened file after animation: ${filePath}`) + } catch (error) { + getLogger().error(`[DiffAnimationController] Failed to reopen file: ${error}`) + } + }, 3000) + } + } catch (error) { + getLogger().error(`[DiffAnimationController] ❌ Animation failed: ${error}`) + throw error + } } /** - * Animate only the changed portion + * Create a smart scan plan based on changed regions */ - private async animatePartialChange( - editor: vscode.TextEditor, - fadedOverlayController: DecorationController, - activeLineController: DecorationController, - startLine: number, - lineCount: number - ): Promise { - // Clear previous decorations - fadedOverlayController.clear() - activeLineController.clear() - - // Apply overlay only to the changed region - fadedOverlayController.addLines(startLine, lineCount) - - // Animate the changed lines - for (let i = 0; i < lineCount; i++) { - const currentLine = startLine + i - - // Highlight current line - activeLineController.setActiveLine(currentLine) + private createScanPlan( + originalContent: string, + newContent: string, + changedRegion: { startLine: number; endLine: number; totalLines: number } + ): { + leftLines: Array + rightLines: Array + scanPlan: Array<{ + leftIndex: number | undefined + rightIndex: number | undefined + leftLine?: DiffLine & { index: number } + rightLine?: DiffLine & { index: number } + preAdded?: boolean + }> + } { + const changes = diffLines(originalContent, newContent) + const leftLines: Array = [] + const rightLines: Array = [] + const scanPlan: Array<{ + leftIndex: number | undefined + rightIndex: number | undefined + leftLine?: DiffLine & { index: number } + rightLine?: DiffLine & { index: number } + preAdded?: boolean + }> = [] + + let leftLineNum = 1 + let rightLineNum = 1 + let leftIndex = 0 + let rightIndex = 0 - // Update overlay - if (i < lineCount - 1) { - fadedOverlayController.clear() - fadedOverlayController.addLines(currentLine + 1, lineCount - i - 1) + for (const change of changes) { + const lines = change.value.split('\n').filter((l) => l !== undefined) + if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) { + continue } - // Animation delay - await new Promise((resolve) => setTimeout(resolve, 30)) - } - - // Clear decorations - fadedOverlayController.clear() - activeLineController.clear() - - // Apply GitHub diff decorations to the changed region - await this.applyPartialGitHubDiffDecorations(editor, startLine, lineCount) - } + if (change.removed) { + // Removed lines only on left + for (const line of lines) { + const diffLine = { + type: 'removed' as const, + content: line, + lineNumber: leftLineNum, + oldLineNumber: leftLineNum++, + index: leftIndex, + leftLineNumber: leftLineNum - 1, + } + leftLines.push(diffLine) + + // Add to scan plan if in changed region + if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: leftIndex, + rightIndex: undefined, + leftLine: diffLine, + }) + } + leftIndex++ + } + } else if (change.added) { + // Added lines only on right + for (const line of lines) { + const diffLine = { + type: 'added' as const, + content: line, + lineNumber: rightLineNum, + newLineNumber: rightLineNum++, + index: rightIndex, + rightLineNumber: rightLineNum - 1, + } + rightLines.push(diffLine) + + // Add to scan plan if in changed region + if (rightIndex >= changedRegion.startLine && rightIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: undefined, + rightIndex: rightIndex, + rightLine: diffLine, + }) + } + rightIndex++ + } + } else { + // Unchanged lines on both sides + for (const line of lines) { + const leftDiffLine = { + type: 'unchanged' as const, + content: line, + lineNumber: leftLineNum, + oldLineNumber: leftLineNum++, + index: leftIndex, + leftLineNumber: leftLineNum - 1, + } - /** - * Apply diff decorations only to changed region - */ - private async applyPartialGitHubDiffDecorations( - editor: vscode.TextEditor, - startLine: number, - lineCount: number - ): Promise { - const additions: vscode.Range[] = [] + const rightDiffLine = { + type: 'unchanged' as const, + content: line, + lineNumber: rightLineNum, + newLineNumber: rightLineNum++, + index: rightIndex, + rightLineNumber: rightLineNum - 1, + } - // Mark all changed lines as additions - for (let i = 0; i < lineCount && startLine + i < editor.document.lineCount; i++) { - additions.push(new vscode.Range(startLine + i, 0, startLine + i, Number.MAX_SAFE_INTEGER)) - } + leftLines.push(leftDiffLine) + rightLines.push(rightDiffLine) + + // Add to scan plan if in changed region + if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: leftIndex, + rightIndex: rightIndex, + leftLine: leftDiffLine, + rightLine: rightDiffLine, + }) + } - // Get or create addition controller - let additionController = this.additionControllers.get(editor.document.uri.fsPath) - if (!additionController) { - additionController = new DecorationController('addition', editor) - this.additionControllers.set(editor.document.uri.fsPath, additionController) + leftIndex++ + rightIndex++ + } + } } - // Apply decorations - additionController.setRanges(additions) - - getLogger().info(`[DiffAnimationController] Applied partial diff decorations: ${additions.length} additions`) + return { leftLines, rightLines, scanPlan } } /** - * Show static GitHub-style diff view for a file + * Show VS Code's built-in diff view (for file tab clicks) */ - public async showStaticDiffView(filePath: string): Promise { - const animation = this.activeAnimations.get(filePath) - if (!animation) { - getLogger().warn(`[DiffAnimationController] No animation data found for: ${filePath}`) - return - } + public async showVSCodeDiff(filePath: string, originalContent: string, newContent: string): Promise { + const fileName = path.basename(filePath) - // Open diff view again (static, no animation) - const fileName = filePath.split(/[\\\/]/).pop() || 'file' - const leftUri = vscode.Uri.parse(`${diffViewUriScheme}:${fileName}`).with({ - query: Buffer.from(animation.originalContent).toString('base64'), - }) - - // Set content for left side - this.contentProvider.setContent(leftUri.toString(), animation.originalContent) + // Close all editors first (Issue #3) + await vscode.commands.executeCommand('workbench.action.closeAllEditors') - // Right side is the actual file with final content - const rightUri = vscode.Uri.file(filePath) + // For new files, use empty content if original is empty + const leftContent = originalContent || '' - // Ensure file has the final content - const doc = await vscode.workspace.openTextDocument(rightUri) - if (doc.getText() !== animation.newContent) { - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - 0, - 0, - doc.lineCount > 0 ? doc.lineCount - 1 : 0, - doc.lineCount > 0 ? doc.lineAt(Math.max(0, doc.lineCount - 1)).text.length : 0 - ) - edit.replace(rightUri, fullRange, animation.newContent) - await vscode.workspace.applyEdit(edit) - } - - const title = `${fileName}: Original ↔ AI's Changes` - - // Execute diff command - await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { - preview: false, - preserveFocus: false, + // Create temporary file for original content with a unique scheme + const leftUri = vscode.Uri.from({ + scheme: 'amazon-q-diff-temp', + path: `${fileName}`, + query: `original=${Date.now()}`, // Add timestamp to make it unique }) - // Wait for diff view to open - await new Promise((resolve) => setTimeout(resolve, 100)) + // Register a one-time content provider for this URI + const disposable = vscode.workspace.registerTextDocumentContentProvider('amazon-q-diff-temp', { + provideTextDocumentContent: (uri) => { + if (uri.toString() === leftUri.toString()) { + return leftContent + } + return '' + }, + }) - // Find the editor - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (!editor) { - getLogger().warn(`[DiffAnimationController] No editor found for static diff view`) - return + try { + // Open diff view + const fileUri = vscode.Uri.file(filePath) + await vscode.commands.executeCommand( + 'vscode.diff', + leftUri, + fileUri, + `${fileName}: ${leftContent ? 'Original' : 'New File'} ↔ Current` + ) + } finally { + // Clean up the content provider after a delay + setTimeout(() => disposable.dispose(), 1000) } - - // Apply GitHub-style diff decorations immediately (no animation) - await this.applyGitHubDiffDecorations(filePath, editor, animation.originalContent, animation.newContent) - - animation.isShowingStaticDiff = true - - getLogger().info(`[DiffAnimationController] Showing static diff view for: ${filePath}`) } - /** - * Exit diff view and restore final content + * Show static diff view (reuse existing webview) */ - public async exitDiffView(filePath: string): Promise { + public async showStaticDiffView(filePath: string): Promise { const animation = this.activeAnimations.get(filePath) - if (!animation || !animation.isShowingStaticDiff) { - return - } - - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (!editor) { + if (!animation) { + getLogger().warn(`[DiffAnimationController] No animation data found for: ${filePath}`) return } - // Clear all decorations - this.additionControllers.get(filePath)?.clear() - this.deletionControllers.get(filePath)?.clear() - - // Restore the final content (without deleted lines) - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, animation.newContent) - await vscode.workspace.applyEdit(edit) - - animation.isShowingStaticDiff = false - animation.diffViewContent = undefined - - getLogger().info(`[DiffAnimationController] Exited diff view for: ${filePath}`) + // Show VS Code diff for static view + await this.showVSCodeDiff(filePath, animation.originalContent, animation.newContent) } /** - * Apply GitHub-style diff decorations with actual deletion lines + * Start partial diff animation */ - private async applyGitHubDiffDecorations( + public async startPartialDiffAnimation( filePath: string, - editor: vscode.TextEditor, originalContent: string, - newContent: string + newContent: string, + options: PartialUpdateOptions = {} ): Promise { - let additionController = this.additionControllers.get(filePath) - let deletionController = this.deletionControllers.get(filePath) - - if (!additionController) { - additionController = new DecorationController('addition', editor) - this.additionControllers.set(filePath, additionController) - } - - if (!deletionController) { - deletionController = new DecorationController('deletion', editor) - this.deletionControllers.set(filePath, deletionController) - } - - // Calculate diff - const changes = diffLines(originalContent, newContent) - const additions: vscode.Range[] = [] - const deletions: vscode.Range[] = [] - - let currentLine = 0 - - for (const change of changes) { - const lines = change.value.split('\n').filter((line) => line !== '') - - if (change.added) { - // Added lines - for (let i = 0; i < lines.length && currentLine + i < editor.document.lineCount; i++) { - additions.push(new vscode.Range(currentLine + i, 0, currentLine + i, Number.MAX_SAFE_INTEGER)) - } - currentLine += lines.length - } else if (change.removed) { - // Skip removed lines (they're shown in the left panel) - } else { - // Unchanged lines - currentLine += lines.length - } - } - - // Apply decorations - additionController.setRanges(additions) - deletionController.setRanges(deletions) - - getLogger().info( - `[DiffAnimationController] Applied GitHub diff: ${additions.length} additions, ${deletions.length} deletions` - ) - - // Store that we're showing diff view - const animation = this.activeAnimations.get(filePath) - if (animation) { - animation.isShowingStaticDiff = true - } + // For now, fall back to full animation + // TODO: Implement partial updates in webview + return this.startDiffAnimation(filePath, originalContent, newContent) } /** - * Scroll editor to line + * Close diff webview for a file */ - private scrollEditorToLine(editor: vscode.TextEditor, line: number): void { - const scrollLine = Math.max(0, line - 5) - editor.revealRange( - new vscode.Range(scrollLine, 0, scrollLine, 0), - vscode.TextEditorRevealType.InCenterIfOutsideViewport - ) + private closeDiffWebview(filePath: string): void { + const webview = this.diffWebviews.get(filePath) + if (webview) { + webview.dispose() + this.diffWebviews.delete(filePath) + } } /** - * Support for incremental diff animation + * Cancel ongoing animation */ - public async startIncrementalDiffAnimation( - filePath: string, - previousContent: string, - currentContent: string, - isFirstUpdate: boolean = false - ): Promise { - getLogger().info(`[DiffAnimationController] đŸŽŦ Starting incremental animation for: ${filePath}`) - - if (isFirstUpdate || previousContent === '') { - return this.startDiffAnimation(filePath, previousContent, currentContent) - } - - // Cancel any ongoing animation - this.cancelAnimation(filePath) - - // For incremental updates, apply changes immediately with flash effect - try { - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (!editor) { - return this.startDiffAnimation(filePath, previousContent, currentContent) - } - - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - const activeLineController = this.activeLineControllers.get(filePath) - - if (!fadedOverlayController || !activeLineController) { - return this.startDiffAnimation(filePath, previousContent, currentContent) - } - - // Apply content change using WorkspaceEdit - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, currentContent) - await vscode.workspace.applyEdit(edit) - - // Flash effect for changed lines - const newLines = currentContent.split('\n') - const prevLines = previousContent.split('\n') - const changedLines: number[] = [] - - for (let i = 0; i < Math.max(newLines.length, prevLines.length); i++) { - if (newLines[i] !== prevLines[i]) { - changedLines.push(i) - } - } + private cancelAnimation(filePath: string): void { + const animation = this.activeAnimations.get(filePath) + if (animation && !animation.isShowingStaticDiff) { + animation.animationCancelled = true - // Apply flash effect - for (const line of changedLines) { - if (line < editor.document.lineCount) { - activeLineController.setActiveLine(line) - await new Promise((resolve) => setTimeout(resolve, 200)) + // Clear timeouts + const timeouts = this.animationTimeouts.get(filePath) + if (timeouts) { + for (const timeout of timeouts) { + clearTimeout(timeout) } + this.animationTimeouts.delete(filePath) } - - // Clear decorations - activeLineController.clear() - fadedOverlayController.clear() - - // Update animation data for the incremental change - const animation = this.activeAnimations.get(filePath) - if (animation) { - animation.originalContent = previousContent - animation.newContent = currentContent - - // Show GitHub-style diff - await this.applyGitHubDiffDecorations(filePath, editor, previousContent, currentContent) - } - } catch (error) { - getLogger().error(`[DiffAnimationController] ❌ Incremental animation failed: ${error}`) - return this.startDiffAnimation(filePath, previousContent, currentContent) } } @@ -1136,75 +1030,18 @@ export class DiffAnimationController { public stopDiffAnimation(filePath: string): void { getLogger().info(`[DiffAnimationController] 🛑 Stopping animation for: ${filePath}`) - // If showing diff view, exit it first - const animation = this.activeAnimations.get(filePath) - if (animation?.isShowingStaticDiff) { - // Restore final content before clearing using WorkspaceEdit - const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.fsPath === filePath) - if (editor && animation.newContent) { - const edit = new vscode.WorkspaceEdit() - const fullRange = new vscode.Range( - editor.document.positionAt(0), - editor.document.positionAt(editor.document.getText().length) - ) - edit.replace(editor.document.uri, fullRange, animation.newContent) - void vscode.workspace.applyEdit(edit).then(() => { - getLogger().info(`[DiffAnimationController] Restored final content for: ${filePath}`) - }) - } - } - - // Cancel animation if running this.cancelAnimation(filePath) + this.closeDiffWebview(filePath) - // Clear all state for this file this.activeAnimations.delete(filePath) this.fileSnapshots.delete(filePath) - this.hiddenEditors.delete(filePath) - - const fadedOverlayController = this.fadedOverlayControllers.get(filePath) - if (fadedOverlayController) { - fadedOverlayController.clear() - this.fadedOverlayControllers.delete(filePath) - } - - const activeLineController = this.activeLineControllers.get(filePath) - if (activeLineController) { - activeLineController.clear() - this.activeLineControllers.delete(filePath) - } - - const additionController = this.additionControllers.get(filePath) - if (additionController) { - additionController.clear() - this.additionControllers.delete(filePath) - } - - const deletionController = this.deletionControllers.get(filePath) - if (deletionController) { - deletionController.clear() - this.deletionControllers.delete(filePath) - } - - const deletionMarkerController = this.deletionMarkerControllers.get(filePath) - if (deletionMarkerController) { - deletionMarkerController.clear() - this.deletionMarkerControllers.delete(filePath) - } - - this.streamedLines.delete(filePath) + this.animationTimeouts.delete(filePath) this.shouldAutoScroll.delete(filePath) - this.lastFirstVisibleLine.delete(filePath) - - const scrollListener = this.scrollListeners.get(filePath) - if (scrollListener) { - scrollListener.dispose() - this.scrollListeners.delete(filePath) - } + this.lastScrollPosition.delete(filePath) } /** - * Stop all active diff animations + * Stop all animations */ public stopAllAnimations(): void { getLogger().info('[DiffAnimationController] 🛑 Stopping all animations') @@ -1214,7 +1051,7 @@ export class DiffAnimationController { } /** - * Check if an animation is currently active for a file + * Check if animating */ public isAnimating(filePath: string): boolean { const animation = this.activeAnimations.get(filePath) @@ -1226,7 +1063,7 @@ export class DiffAnimationController { } /** - * Check if showing static diff for a file + * Check if showing static diff */ public isShowingStaticDiff(filePath: string): boolean { const animation = this.activeAnimations.get(filePath) @@ -1234,7 +1071,7 @@ export class DiffAnimationController { } /** - * Get animation statistics + * Get animation stats */ public getAnimationStats(): { activeCount: number; filePaths: string[] } { return { @@ -1243,19 +1080,17 @@ export class DiffAnimationController { } } + /** + * Dispose + */ public dispose(): void { getLogger().info('[DiffAnimationController] đŸ’Ĩ Disposing controller') this.stopAllAnimations() - // Dispose content provider - this.providerDisposable.dispose() - this.contentProvider.dispose() - - // Dispose decoration types - fadedOverlayDecorationType.dispose() - activeLineDecorationType.dispose() - githubAdditionDecorationType.dispose() - githubDeletionDecorationType.dispose() - deletionMarkerDecorationType.dispose() + // Close all webviews + for (const [_, webview] of this.diffWebviews) { + webview.dispose() + } + this.diffWebviews.clear() } } diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index 9357c143ac7..327608be08f 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -262,8 +262,6 @@ export class DiffAnimationHandler implements vscode.Disposable { // Open/create the file to make it visible try { - const uri = vscode.Uri.file(filePath) - if (!fileExists) { // Create directory if needed const directory = path.dirname(filePath) @@ -275,15 +273,10 @@ export class DiffAnimationHandler implements vscode.Disposable { `[DiffAnimationHandler] 📁 Directory prepared, file will be created by write operation` ) } else { - // Open the document (but keep it in background) - const document = await vscode.workspace.openTextDocument(uri) - await vscode.window.showTextDocument(document, { - preview: false, - preserveFocus: true, // Keep focus on current editor - viewColumn: vscode.ViewColumn.One, // Open in first column - }) - } + // DON'T open the document visually - we'll use diff view instead + getLogger().info(`[DiffAnimationHandler] 📄 File exists and is accessible: ${filePath}`) + } getLogger().info(`[DiffAnimationHandler] ✅ File prepared: ${filePath}`) } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Failed to prepare file: ${error}`) @@ -292,6 +285,9 @@ export class DiffAnimationHandler implements vscode.Disposable { } } + /** + * Handle file changes - this is where we detect the actual write + */ /** * Handle file changes - this is where we detect the actual write */ @@ -313,17 +309,22 @@ export class DiffAnimationHandler implements vscode.Disposable { await new Promise((resolve) => setTimeout(resolve, 50)) try { - // Read the new content + // Read the new content that was just written const newContentBuffer = await vscode.workspace.fs.readFile(uri) const newContent = Buffer.from(newContentBuffer).toString('utf8') // Check if content actually changed if (pendingWrite.originalContent !== newContent) { getLogger().info( - `[DiffAnimationHandler] đŸŽŦ Content changed, checking animation status - ` + + `[DiffAnimationHandler] đŸŽŦ Content changed - ` + `original: ${pendingWrite.originalContent.length} chars, new: ${newContent.length} chars` ) + // Note: We do NOT restore the original content to the file + // The webview will show a virtual diff animation independently + // This avoids interfering with AI's file operations + getLogger().info(`[DiffAnimationHandler] 📝 File has new content, will show virtual diff animation`) + // If already animating, queue the change if (this.animatingFiles.has(filePath)) { const queue = this.animationQueue.get(filePath) || [] @@ -340,7 +341,8 @@ export class DiffAnimationHandler implements vscode.Disposable { return } - // Start animation + // Start animation with the captured new content + // The controller will apply the new content after animation completes await this.startAnimation(filePath, pendingWrite, newContent) } else { getLogger().info(`[DiffAnimationHandler] â„šī¸ No content change for: ${filePath}`) @@ -566,7 +568,10 @@ export class DiffAnimationHandler implements vscode.Disposable { /** * Show static diff view for a file (when clicked from chat) */ - public async showStaticDiffForFile(filePath: string): Promise { + /** + * Show static diff view for a file (when clicked from chat) + */ + public async showStaticDiffForFile(filePath: string, originalContent?: string, newContent?: string): Promise { getLogger().info(`[DiffAnimationHandler] 👆 File clicked from chat: ${filePath}`) // Normalize the file path @@ -576,8 +581,22 @@ export class DiffAnimationHandler implements vscode.Disposable { return } - // Show static diff view without animation - await this.diffAnimationController.showStaticDiffView(normalizedPath) + // Get animation data if it exists + const animation = this.diffAnimationController.getAnimationData(normalizedPath) + + // Use provided content or animation data + const origContent = originalContent || animation?.originalContent + const newContentToUse = newContent || animation?.newContent + + if (origContent !== undefined && newContentToUse !== undefined) { + // Show VS Code's built-in diff view + await this.diffAnimationController.showVSCodeDiff(normalizedPath, origContent, newContentToUse) + } else { + // If no content available, just open the file + getLogger().warn(`[DiffAnimationHandler] No diff content available, opening file normally`) + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(normalizedPath)) + await vscode.window.showTextDocument(doc) + } } /** diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 954c3f43af2..993fa34e371 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -522,17 +522,21 @@ export function registerMessageListeners( getLogger().info(`[VSCode Client] OpenFileDiff notification for: ${normalizedPath}`) - // Check if we should show static diff (same content as last animation) + // Check if we should show static diff if (animationHandler.shouldShowStaticDiff(normalizedPath, newContent)) { - getLogger().info('[VSCode Client] Same content as last animation, showing static diff') - await animationHandler.showStaticDiffForFile(normalizedPath) + getLogger().info('[VSCode Client] From ChatClick, showing static diff') + await animationHandler.showStaticDiffForFile( + normalizedPath, + params.originalFileContent || '', + params.fileContent || '' + ) } else { getLogger().info('[VSCode Client] New content detected, starting animation') // This is from chat click, pass the flag await animationHandler.processFileDiff({ originalFileUri: params.originalFileUri, - originalFileContent: params.originalFileContent, - fileContent: params.fileContent, + originalFileContent: params.originalFileContent || '', + fileContent: params.fileContent || '', isFromChatClick: true, }) } From 7de7ffc9c8197727314c0499f877a708427d7dbf Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Sun, 15 Jun 2025 20:58:16 -0700 Subject: [PATCH 14/32] refactor the diffAnimation codes and write unit tests --- .../src/lsp/chat/diffAnimation/README.md | 157 ++++ .../diffAnimation/animationQueueManager.ts | 178 ++++ .../lsp/chat/diffAnimation/chatProcessor.ts | 151 ++++ .../lsp/chat/diffAnimation/diffAnalyzer.ts | 320 ++++++++ .../diffAnimation/diffAnimationController.ts | 771 ++---------------- .../diffAnimation/diffAnimationHandler.ts | 492 ++--------- .../chat/diffAnimation/fileSystemManager.ts | 215 +++++ .../src/lsp/chat/diffAnimation/types.ts | 88 ++ .../chat/diffAnimation/vscodeIntegration.ts | 284 +++++++ .../lsp/chat/diffAnimation/webviewManager.ts | 465 +++++++++++ packages/amazonq/src/lsp/chat/messages.ts | 33 +- .../diffAnimationController.test.ts | 620 ++++++++++++++ .../diffAnimationHandler.test.ts | 471 +++++++++++ 13 files changed, 3078 insertions(+), 1167 deletions(-) create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/README.md create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/animationQueueManager.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/chatProcessor.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/diffAnalyzer.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/fileSystemManager.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/types.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/vscodeIntegration.ts create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/webviewManager.ts create mode 100644 packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts create mode 100644 packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/README.md b/packages/amazonq/src/lsp/chat/diffAnimation/README.md new file mode 100644 index 00000000000..fe2a9155018 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/README.md @@ -0,0 +1,157 @@ +# DiffAnimation Module Refactoring + +## Overview + +The `diffAnimation` directory has been refactored from 2 large files (~1700 lines total) into 9 smaller, focused modules following the Single Responsibility Principle. This improves maintainability, testability, and code organization while preserving all existing functionality. + +## File Structure + +### Core Files + +- **`diffAnimationHandler.ts`** - Main orchestrator and public API (reduced from ~800 to ~300 lines) +- **`diffAnimationController.ts`** - Animation control and coordination (reduced from ~900 to ~400 lines) + +### Supporting Components + +- **`types.ts`** - Shared TypeScript interfaces and types +- **`fileSystemManager.ts`** - File system operations, path resolution, and file watching +- **`chatProcessor.ts`** - Chat message processing and tool use handling +- **`animationQueueManager.ts`** - Animation queuing and coordination logic +- **`webviewManager.ts`** - Webview creation, HTML generation, and messaging +- **`diffAnalyzer.ts`** - Diff calculation, line parsing, and scan planning +- **`vscodeIntegration.ts`** - VS Code API integration and utilities + +## Architecture + +``` +DiffAnimationHandler (Main Entry Point) +├── FileSystemManager (File Operations) +├── ChatProcessor (Message Processing) +├── AnimationQueueManager (Queue Management) +└── DiffAnimationController (Animation Control) + ├── WebviewManager (UI Management) + ├── DiffAnalyzer (Diff Logic) + └── VSCodeIntegration (VS Code APIs) +``` + +## Key Benefits + +### 1. **Improved Maintainability** + +- Each component has a single, clear responsibility +- Easier to locate and modify specific functionality +- Reduced cognitive load when working on individual features + +### 2. **Better Testability** + +- Components can be unit tested in isolation +- Dependencies are injected, making mocking easier +- Clear interfaces between components + +### 3. **Enhanced Reusability** + +- Components can be reused in different contexts +- Easier to extract functionality for other features +- Clear separation of concerns + +### 4. **Preserved Functionality** + +- All existing public APIs remain unchanged +- No breaking changes to external consumers +- Backward compatibility maintained + +## Component Responsibilities + +### FileSystemManager + +- File system watching and event handling +- Path resolution and normalization +- File content capture and preparation +- Directory creation and file operations + +### ChatProcessor + +- Chat message parsing and processing +- Tool use detection and handling +- Message deduplication +- File write preparation coordination + +### AnimationQueueManager + +- Animation queuing for concurrent file changes +- Animation state management +- Queue processing and coordination +- Statistics and monitoring + +### WebviewManager + +- Webview panel creation and management +- HTML content generation +- Message passing between extension and webview +- Auto-scroll control and user interaction handling + +### DiffAnalyzer + +- Diff calculation and analysis +- Changed region detection +- Scan plan creation for animations +- Animation timing calculations +- Complexity analysis for optimization + +### VSCodeIntegration + +- VS Code API abstractions +- Built-in diff view integration +- Editor operations and file management +- Status messages and user notifications +- Configuration and theme management + +## Migration Notes + +### For Developers + +- Import paths remain the same for main classes +- All public methods and interfaces are preserved +- Internal implementation is now modular but transparent to consumers + +### For Testing + +- Individual components can now be tested in isolation +- Mock dependencies can be easily injected +- Test coverage can be more granular and focused + +### For Future Development + +- New features can be added to specific components +- Components can be enhanced without affecting others +- Clear boundaries make refactoring safer and easier + +## ESLint Compliance + +All files follow the project's ESLint configuration: + +- Proper TypeScript typing +- Consistent code formatting +- No unused imports or variables +- Proper error handling patterns + +## Performance Considerations + +- No performance impact from refactoring +- Same memory usage patterns +- Identical animation behavior +- Preserved optimization strategies + +## Future Enhancements + +The modular structure enables several future improvements: + +1. **Enhanced Testing**: Unit tests for individual components +2. **Performance Monitoring**: Better metrics collection per component +3. **Feature Extensions**: Easier addition of new animation types +4. **Configuration**: Component-level configuration options +5. **Debugging**: Better error isolation and debugging capabilities + +## Conclusion + +This refactoring successfully breaks down the large `diffAnimation` codebase into manageable, focused components while maintaining full backward compatibility and functionality. The new structure provides a solid foundation for future development and maintenance. diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/animationQueueManager.ts b/packages/amazonq/src/lsp/chat/diffAnimation/animationQueueManager.ts new file mode 100644 index 00000000000..73819c03507 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/animationQueueManager.ts @@ -0,0 +1,178 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from 'aws-core-vscode/shared' +import { QueuedAnimation, PendingFileWrite } from './types' +import { FileSystemManager } from './fileSystemManager' + +export class AnimationQueueManager { + // Track which files are being animated + private animatingFiles = new Set() + // Animation queue for handling multiple changes + private animationQueue = new Map() + + constructor( + private fileSystemManager: FileSystemManager, + private startFullAnimation: ( + filePath: string, + originalContent: string, + newContent: string, + toolUseId: string + ) => Promise, + private startPartialAnimation: ( + filePath: string, + originalContent: string, + newContent: string, + changeLocation: { startLine: number; endLine: number }, + toolUseId: string + ) => Promise + ) {} + + /** + * Check if a file is currently being animated + */ + public isAnimating(filePath: string): boolean { + return this.animatingFiles.has(filePath) + } + + /** + * Mark file as animating + */ + public markAsAnimating(filePath: string): void { + this.animatingFiles.add(filePath) + } + + /** + * Mark file as no longer animating + */ + public markAsNotAnimating(filePath: string): void { + this.animatingFiles.delete(filePath) + } + + /** + * Queue an animation for later processing + */ + public queueAnimation(filePath: string, animation: QueuedAnimation): void { + const queue = this.animationQueue.get(filePath) || [] + queue.push(animation) + this.animationQueue.set(filePath, queue) + getLogger().info(`[AnimationQueueManager] 📋 Queued animation for ${filePath} (queue size: ${queue.length})`) + } + + /** + * Start animation and handle queuing logic + */ + public async startAnimation(filePath: string, pendingWrite: PendingFileWrite, newContent: string): Promise { + // If already animating, queue the change + if (this.isAnimating(filePath)) { + this.queueAnimation(filePath, { + originalContent: pendingWrite.originalContent, + newContent, + toolUseId: pendingWrite.toolUseId, + changeLocation: pendingWrite.changeLocation, + }) + return + } + + // Mark as animating + this.markAsAnimating(filePath) + + try { + // Check if we have change location for partial update + if (pendingWrite.changeLocation) { + // Use partial animation for targeted changes + await this.startPartialAnimation( + filePath, + pendingWrite.originalContent, + newContent, + pendingWrite.changeLocation, + pendingWrite.toolUseId + ) + } else { + // Use full file animation + await this.startFullAnimation( + filePath, + pendingWrite.originalContent, + newContent, + pendingWrite.toolUseId + ) + } + + // Process queued animations + await this.processQueuedAnimations(filePath) + } finally { + // Always mark as not animating when done + this.markAsNotAnimating(filePath) + } + } + + /** + * Process queued animations for a file + */ + private async processQueuedAnimations(filePath: string): Promise { + const queue = this.animationQueue.get(filePath) + if (!queue || queue.length === 0) { + return + } + + const next = queue.shift() + if (!next) { + return + } + + getLogger().info( + `[AnimationQueueManager] đŸŽ¯ Processing queued animation for ${filePath} (${queue.length} remaining)` + ) + + // Use the current file content as the "original" for the next animation + const currentContent = await this.fileSystemManager.getCurrentFileContent(filePath) + + // Create a new pending write for the queued animation + const queuedPendingWrite: PendingFileWrite = { + filePath, + originalContent: currentContent, + toolUseId: next.toolUseId, + timestamp: Date.now(), + changeLocation: next.changeLocation, + } + + // Recursively start the next animation + await this.startAnimation(filePath, queuedPendingWrite, next.newContent) + } + + /** + * Get animation statistics + */ + public getAnimationStats(): { animatingCount: number; queuedCount: number; filePaths: string[] } { + let queuedCount = 0 + for (const queue of this.animationQueue.values()) { + queuedCount += queue.length + } + + return { + animatingCount: this.animatingFiles.size, + queuedCount, + filePaths: Array.from(this.animatingFiles), + } + } + + /** + * Clear all queues and reset state + */ + public clearAll(): void { + this.animatingFiles.clear() + this.animationQueue.clear() + getLogger().info('[AnimationQueueManager] 🧹 Cleared all animation queues and state') + } + + /** + * Clear queue for a specific file + */ + public clearFileQueue(filePath: string): void { + this.animationQueue.delete(filePath) + this.markAsNotAnimating(filePath) + getLogger().info(`[AnimationQueueManager] 🧹 Cleared queue for ${filePath}`) + } +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/chatProcessor.ts b/packages/amazonq/src/lsp/chat/diffAnimation/chatProcessor.ts new file mode 100644 index 00000000000..56ad2ee7bc0 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/chatProcessor.ts @@ -0,0 +1,151 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' +import { getLogger } from 'aws-core-vscode/shared' +import { PendingFileWrite } from './types' +import { FileSystemManager } from './fileSystemManager' + +export class ChatProcessor { + // Track processed messages to avoid duplicates + private processedMessages = new Set() + + constructor( + private fileSystemManager: FileSystemManager, + private onFileWritePreparation: (pendingWrite: PendingFileWrite) => Promise + ) {} + + /** + * Process streaming ChatResult updates + */ + public async processChatResult( + chatResult: ChatResult | ChatMessage, + tabId: string, + isPartialResult?: boolean + ): Promise { + getLogger().info(`[ChatProcessor] 📨 Processing ChatResult for tab ${tabId}, isPartial: ${isPartialResult}`) + + try { + // Handle both ChatResult and ChatMessage types + if ('type' in chatResult && chatResult.type === 'tool') { + // This is a ChatMessage + await this.processChatMessage(chatResult as ChatMessage, tabId) + } else if ('additionalMessages' in chatResult && chatResult.additionalMessages) { + // This is a ChatResult with additional messages + for (const message of chatResult.additionalMessages) { + await this.processChatMessage(message, tabId) + } + } + } catch (error) { + getLogger().error(`[ChatProcessor] ❌ Failed to process chat result: ${error}`) + } + } + + /** + * Process individual chat messages + */ + private async processChatMessage(message: ChatMessage, tabId: string): Promise { + if (!message.messageId) { + return + } + + // Deduplicate messages + const messageKey = `${message.messageId}_${message.type}` + if (this.processedMessages.has(messageKey)) { + getLogger().info(`[ChatProcessor] â­ī¸ Already processed message: ${messageKey}`) + return + } + this.processedMessages.add(messageKey) + + // Check for fsWrite tool preparation (when tool is about to execute) + if (message.type === 'tool' && message.messageId.startsWith('progress_')) { + await this.processFsWritePreparation(message, tabId) + } + } + + /** + * Process fsWrite preparation - capture content BEFORE file is written + */ + private async processFsWritePreparation(message: ChatMessage, tabId: string): Promise { + // Cast to any to access properties that might not be in the type definition + const messageAny = message as any + + const fileList = messageAny.header?.fileList + if (!fileList?.filePaths || fileList.filePaths.length === 0) { + return + } + + const fileName = fileList.filePaths[0] + const fileDetails = fileList.details?.[fileName] + + if (!fileDetails?.description) { + return + } + + const filePath = await this.fileSystemManager.resolveFilePath(fileDetails.description) + if (!filePath) { + return + } + + // Extract toolUseId from progress message + const toolUseId = message.messageId!.replace('progress_', '') + + getLogger().info(`[ChatProcessor] đŸŽŦ Preparing for fsWrite: ${filePath} (toolUse: ${toolUseId})`) + + // Capture current content IMMEDIATELY before the write happens + const { content: originalContent, exists: fileExists } = + await this.fileSystemManager.captureFileContent(filePath) + + // Store pending write info + const pendingWrite: PendingFileWrite = { + filePath, + originalContent, + toolUseId, + timestamp: Date.now(), + } + + try { + // Prepare file for writing + await this.fileSystemManager.prepareFileForWrite(filePath, fileExists) + + // Notify handler about the pending write + await this.onFileWritePreparation(pendingWrite) + } catch (error) { + getLogger().error(`[ChatProcessor] ❌ Failed to prepare file write: ${error}`) + throw error + } + } + + /** + * Process ChatUpdateParams + */ + public async processChatUpdate(params: ChatUpdateParams): Promise { + getLogger().info(`[ChatProcessor] 🔄 Processing chat update for tab ${params.tabId}`) + + if (params.data?.messages) { + for (const message of params.data.messages) { + await this.processChatMessage(message, params.tabId) + } + } + } + + /** + * Clear processed messages cache + */ + public clearProcessedMessages(): void { + if (this.processedMessages.size > 1000) { + const oldSize = this.processedMessages.size + this.processedMessages.clear() + getLogger().info(`[ChatProcessor] 🧹 Cleared ${oldSize} processed messages`) + } + } + + /** + * Clear all caches + */ + public clearAll(): void { + this.processedMessages.clear() + } +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnalyzer.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnalyzer.ts new file mode 100644 index 00000000000..dccf524ad40 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnalyzer.ts @@ -0,0 +1,320 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from 'aws-core-vscode/shared' +import { diffLines } from 'diff' +import { DiffLine, ChangedRegion, ScanPlan } from './types' + +export class DiffAnalyzer { + constructor() { + getLogger().info('[DiffAnalyzer] 🚀 Initialized diff analyzer') + } + + /** + * Calculate the changed region between original and new content + */ + public calculateChangedRegion(originalContent: string, newContent: string): ChangedRegion { + // For new files, animate all lines + if (!originalContent || originalContent === '') { + const lines = newContent.split('\n') + return { + startLine: 0, + endLine: Math.min(lines.length - 1, 99), // Cap at 100 lines + totalLines: lines.length, + } + } + + const changes = diffLines(originalContent, newContent) + let minChangedLine = Infinity + let maxChangedLine = -1 + let currentLine = 0 + const newLines = newContent.split('\n') + + for (const change of changes) { + const changeLines = change.value.split('\n') + // Remove empty last element from split + if (changeLines[changeLines.length - 1] === '') { + changeLines.pop() + } + + if (change.added || change.removed) { + minChangedLine = Math.min(minChangedLine, currentLine) + maxChangedLine = Math.max(maxChangedLine, currentLine + changeLines.length - 1) + } + + if (!change.removed) { + currentLine += changeLines.length + } + } + + // If no changes found, animate the whole file + if (minChangedLine === Infinity) { + return { + startLine: 0, + endLine: Math.min(newLines.length - 1, 99), + totalLines: newLines.length, + } + } + + // Add context lines (3 before and after) + const contextLines = 3 + const startLine = Math.max(0, minChangedLine - contextLines) + const endLine = Math.min(newLines.length - 1, maxChangedLine + contextLines) + + // Cap at 100 lines for performance + const animationLines = endLine - startLine + 1 + if (animationLines > 100) { + getLogger().info(`[DiffAnalyzer] Capping animation from ${animationLines} to 100 lines`) + return { + startLine, + endLine: startLine + 99, + totalLines: newLines.length, + } + } + + return { + startLine, + endLine, + totalLines: newLines.length, + } + } + + /** + * Create a smart scan plan based on changed regions + */ + public createScanPlan(originalContent: string, newContent: string, changedRegion: ChangedRegion): ScanPlan { + const changes = diffLines(originalContent, newContent) + const leftLines: Array = [] + const rightLines: Array = [] + const scanPlan: Array<{ + leftIndex: number | undefined + rightIndex: number | undefined + leftLine?: DiffLine & { index: number } + rightLine?: DiffLine & { index: number } + preAdded?: boolean + }> = [] + + let leftLineNum = 1 + let rightLineNum = 1 + let leftIndex = 0 + let rightIndex = 0 + + for (const change of changes) { + const lines = change.value.split('\n').filter((l) => l !== undefined) + if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) { + continue + } + + if (change.removed) { + // Removed lines only on left + for (const line of lines) { + const diffLine = { + type: 'removed' as const, + content: line, + lineNumber: leftLineNum, + oldLineNumber: leftLineNum++, + index: leftIndex, + leftLineNumber: leftLineNum - 1, + } + leftLines.push(diffLine) + + // Add to scan plan if in changed region + if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: leftIndex, + rightIndex: undefined, + leftLine: diffLine, + }) + } + leftIndex++ + } + } else if (change.added) { + // Added lines only on right + for (const line of lines) { + const diffLine = { + type: 'added' as const, + content: line, + lineNumber: rightLineNum, + newLineNumber: rightLineNum++, + index: rightIndex, + rightLineNumber: rightLineNum - 1, + } + rightLines.push(diffLine) + + // Add to scan plan if in changed region + if (rightIndex >= changedRegion.startLine && rightIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: undefined, + rightIndex: rightIndex, + rightLine: diffLine, + }) + } + rightIndex++ + } + } else { + // Unchanged lines on both sides + for (const line of lines) { + const leftDiffLine = { + type: 'unchanged' as const, + content: line, + lineNumber: leftLineNum, + oldLineNumber: leftLineNum++, + index: leftIndex, + leftLineNumber: leftLineNum - 1, + } + + const rightDiffLine = { + type: 'unchanged' as const, + content: line, + lineNumber: rightLineNum, + newLineNumber: rightLineNum++, + index: rightIndex, + rightLineNumber: rightLineNum - 1, + } + + leftLines.push(leftDiffLine) + rightLines.push(rightDiffLine) + + // Add to scan plan if in changed region + if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { + scanPlan.push({ + leftIndex: leftIndex, + rightIndex: rightIndex, + leftLine: leftDiffLine, + rightLine: rightDiffLine, + }) + } + + leftIndex++ + rightIndex++ + } + } + } + + return { leftLines, rightLines, scanPlan } + } + + /** + * Parse diff lines for display + */ + public parseDiffLines( + originalContent: string, + newContent: string + ): { + leftLines: DiffLine[] + rightLines: DiffLine[] + } { + const changes = diffLines(originalContent, newContent) + const leftLines: DiffLine[] = [] + const rightLines: DiffLine[] = [] + + let leftLineNum = 1 + let rightLineNum = 1 + + for (const change of changes) { + const lines = change.value.split('\n').filter((l, i, arr) => { + // Keep all lines except the last empty one from split + return i < arr.length - 1 || l !== '' + }) + + if (change.removed) { + // Removed lines only appear on left + for (const line of lines) { + leftLines.push({ + type: 'removed', + content: line, + lineNumber: leftLineNum++, + oldLineNumber: leftLineNum - 1, + }) + } + } else if (change.added) { + // Added lines only appear on right + for (const line of lines) { + rightLines.push({ + type: 'added', + content: line, + lineNumber: rightLineNum++, + newLineNumber: rightLineNum - 1, + }) + } + } else { + // Unchanged lines appear on both sides + for (const line of lines) { + leftLines.push({ + type: 'unchanged', + content: line, + lineNumber: leftLineNum++, + oldLineNumber: leftLineNum - 1, + }) + + rightLines.push({ + type: 'unchanged', + content: line, + lineNumber: rightLineNum++, + newLineNumber: rightLineNum - 1, + }) + } + } + } + + return { leftLines, rightLines } + } + + /** + * Calculate animation timing based on content size + */ + public calculateAnimationTiming(scanPlanLength: number): { + scanDelay: number + totalDuration: number + } { + const scanDelay = scanPlanLength > 50 ? 40 : 70 + const totalDuration = scanPlanLength * scanDelay + + return { scanDelay, totalDuration } + } + + /** + * Analyze diff complexity for optimization decisions + */ + public analyzeDiffComplexity( + originalContent: string, + newContent: string + ): { + isSimple: boolean + lineCount: number + changeRatio: number + recommendation: 'full' | 'partial' | 'static' + } { + const originalLines = originalContent.split('\n').length + const newLines = newContent.split('\n').length + const maxLines = Math.max(originalLines, newLines) + + const changes = diffLines(originalContent, newContent) + let changedLines = 0 + + for (const change of changes) { + if (change.added || change.removed) { + changedLines += change.value.split('\n').length - 1 + } + } + + const changeRatio = maxLines > 0 ? changedLines / maxLines : 0 + const isSimple = maxLines < 50 && changeRatio < 0.5 + + let recommendation: 'full' | 'partial' | 'static' = 'full' + if (maxLines > 200) { + recommendation = 'static' + } else if (changeRatio < 0.3 && maxLines > 20) { + recommendation = 'partial' + } + + return { + isSimple, + lineCount: maxLines, + changeRatio, + recommendation, + } + } +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts index a147f450ad0..439df24bc7d 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationController.ts @@ -15,58 +15,32 @@ */ import * as vscode from 'vscode' -import * as path from 'path' import { getLogger } from 'aws-core-vscode/shared' -import { diffLines } from 'diff' +import { DiffAnimation, PartialUpdateOptions, AnimationHistory } from './types' +import { WebviewManager } from './webviewManager' +import { DiffAnalyzer } from './diffAnalyzer' +import { VSCodeIntegration } from './vscodeIntegration' -export interface DiffAnimation { - uri: vscode.Uri - originalContent: string - newContent: string - isShowingStaticDiff?: boolean - animationCancelled?: boolean - isFromChatClick?: boolean -} - -export interface PartialUpdateOptions { - changeLocation?: { - startLine: number - endLine: number - startChar?: number - endChar?: number - } - searchContent?: string - isPartialUpdate?: boolean -} - -interface DiffLine { - type: 'unchanged' | 'added' | 'removed' - content: string - lineNumber: number - oldLineNumber?: number - newLineNumber?: number -} +export { DiffAnimation, PartialUpdateOptions } export class DiffAnimationController { private activeAnimations = new Map() - private diffWebviews = new Map() - private fileAnimationHistory = new Map< - string, - { - lastAnimatedContent: string - animationCount: number - isCurrentlyAnimating: boolean - } - >() + private fileAnimationHistory = new Map() private animationTimeouts = new Map() private fileSnapshots = new Map() - // Auto-scroll control - private shouldAutoScroll = new Map() - private lastScrollPosition = new Map() + // Component managers + private webviewManager: WebviewManager + private diffAnalyzer: DiffAnalyzer + private vscodeIntegration: VSCodeIntegration constructor() { getLogger().info('[DiffAnimationController] 🚀 Initialized with progressive scanning animation') + + // Initialize component managers + this.webviewManager = new WebviewManager() + this.diffAnalyzer = new DiffAnalyzer() + this.vscodeIntegration = new VSCodeIntegration() } public getAnimationData(filePath: string): DiffAnimation | undefined { @@ -78,15 +52,21 @@ export class DiffAnimationController { */ public shouldShowStaticDiff(filePath: string, newContent: string): boolean { const history = this.fileAnimationHistory.get(filePath) - if (!history) { - return false + const animation = this.activeAnimations.get(filePath) + + // If we have active animation data, we should show static diff + if (animation) { + return true } - if (history.isCurrentlyAnimating) { - return false + // If we have history and it's not currently animating, show static diff + if (history && !history.isCurrentlyAnimating) { + return true } - return true + // For new files without history, check if we should show static diff + // This handles the case where a file tab is clicked before any animation has occurred + return false } /** @@ -116,78 +96,6 @@ export class DiffAnimationController { } } - /** - * Calculate the changed region between original and new content - */ - private calculateChangedRegion( - originalContent: string, - newContent: string - ): { startLine: number; endLine: number; totalLines: number } { - // For new files, animate all lines - if (!originalContent || originalContent === '') { - const lines = newContent.split('\n') - return { - startLine: 0, - endLine: Math.min(lines.length - 1, 99), // Cap at 100 lines - totalLines: lines.length, - } - } - - const changes = diffLines(originalContent, newContent) - let minChangedLine = Infinity - let maxChangedLine = -1 - let currentLine = 0 - const newLines = newContent.split('\n') - - for (const change of changes) { - const changeLines = change.value.split('\n') - // Remove empty last element from split - if (changeLines[changeLines.length - 1] === '') { - changeLines.pop() - } - - if (change.added || change.removed) { - minChangedLine = Math.min(minChangedLine, currentLine) - maxChangedLine = Math.max(maxChangedLine, currentLine + changeLines.length - 1) - } - - if (!change.removed) { - currentLine += changeLines.length - } - } - - // If no changes found, animate the whole file - if (minChangedLine === Infinity) { - return { - startLine: 0, - endLine: Math.min(newLines.length - 1, 99), - totalLines: newLines.length, - } - } - - // Add context lines (3 before and after) - const contextLines = 3 - const startLine = Math.max(0, minChangedLine - contextLines) - const endLine = Math.min(newLines.length - 1, maxChangedLine + contextLines) - - // Cap at 100 lines for performance - const animationLines = endLine - startLine + 1 - if (animationLines > 100) { - getLogger().info(`[DiffAnimationController] Capping animation from ${animationLines} to 100 lines`) - return { - startLine, - endLine: startLine + 99, - totalLines: newLines.length, - } - } - - return { - startLine, - endLine, - totalLines: newLines.length, - } - } - /** * Start a diff animation for a file */ @@ -253,17 +161,13 @@ export class DiffAnimationController { await doc.save() // Calculate changed region for optimization - const changedRegion = this.calculateChangedRegion(originalContent, newContent) + const changedRegion = this.diffAnalyzer.calculateChangedRegion(originalContent, newContent) getLogger().info( `[DiffAnimationController] Changed region: lines ${changedRegion.startLine}-${changedRegion.endLine}` ) - // Initialize scroll control - this.shouldAutoScroll.set(filePath, true) - this.lastScrollPosition.set(filePath, 0) - // Create or reuse webview for this file - const webview = await this.getOrCreateDiffWebview(filePath) + const webview = await this.webviewManager.getOrCreateDiffWebview(filePath) // Start the progressive animation await this.animateDiffInWebview(filePath, webview, originalContent, newContent, animation, changedRegion) @@ -274,386 +178,6 @@ export class DiffAnimationController { } } - /** - * Get or create a webview panel for diff display - */ - private async getOrCreateDiffWebview(filePath: string): Promise { - await vscode.commands.executeCommand('workbench.action.closeAllEditors') - // Check if we already have a webview for this file - let webview = this.diffWebviews.get(filePath) - if (webview) { - // Reveal existing webview - webview.reveal(vscode.ViewColumn.One) - return webview - } - - // Create new webview - const fileName = path.basename(filePath) - webview = vscode.window.createWebviewPanel('amazonQDiff', `Diff: ${fileName}`, vscode.ViewColumn.One, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [], - }) - - // Store webview - this.diffWebviews.set(filePath, webview) - // Handle webview disposal - webview.onDidDispose(() => { - this.diffWebviews.delete(filePath) - this.stopDiffAnimation(filePath) - // Reopen the original file editor after webview is disposed - Promise.resolve( - vscode.workspace.openTextDocument(vscode.Uri.file(filePath)).then((doc) => { - void vscode.window.showTextDocument(doc, { - preview: false, - viewColumn: vscode.ViewColumn.One, - }) - }) - ).catch((error) => { - getLogger().error(`[DiffAnimationController] Failed to reopen file after webview disposal: ${error}`) - }) - }) - // Handle messages from webview (including scroll events) - webview.webview.onDidReceiveMessage((message) => { - if (message.command === 'userScrolled') { - const currentPosition = message.scrollTop - const lastPosition = this.lastScrollPosition.get(filePath) || 0 - - // If user scrolled up, disable auto-scroll - if (currentPosition < lastPosition - 50) { - // 50px threshold - this.shouldAutoScroll.set(filePath, false) - getLogger().info(`[DiffAnimationController] Auto-scroll disabled for: ${filePath}`) - } - - this.lastScrollPosition.set(filePath, currentPosition) - } - }) - - // Set initial HTML - webview.webview.html = this.getDiffWebviewContent() - - return webview - } - - /** - * Get the HTML content for the diff webview - */ - private getDiffWebviewContent(): string { - return ` - - - - - Diff View - - - -
-
-
Original
-
-
Scanning changes...
-
-
-
-
AI's Changes
-
-
Scanning changes...
-
-
-
- -
- Scanning line 0 -
- - - -` - } - /** * Animate diff in webview progressively with smart scanning */ @@ -667,13 +191,16 @@ export class DiffAnimationController { ): Promise { try { // Parse diff and create scan plan - const { leftLines, rightLines, scanPlan } = this.createScanPlan(originalContent, newContent, changedRegion) + const { leftLines, rightLines, scanPlan } = this.diffAnalyzer.createScanPlan( + originalContent, + newContent, + changedRegion + ) // Clear and start scan - await webview.webview.postMessage({ command: 'clear' }) - await new Promise((resolve) => setTimeout(resolve, 50)) + await this.webviewManager.sendMessageToWebview(filePath, { command: 'clear' }) - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'startScan', totalLines: scanPlan.length, }) @@ -681,7 +208,7 @@ export class DiffAnimationController { // Pre-add lines that are before the scan region (context) for (let i = 0; i < Math.min(changedRegion.startLine, 3); i++) { if (leftLines[i]) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'left', line: leftLines[i], @@ -689,7 +216,7 @@ export class DiffAnimationController { }) } if (rightLines[i]) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'right', line: rightLines[i], @@ -699,7 +226,7 @@ export class DiffAnimationController { } // Calculate animation speed - const scanDelay = scanPlan.length > 50 ? 40 : 70 + const { scanDelay } = this.diffAnalyzer.calculateAnimationTiming(scanPlan.length) // Execute scan plan for (const scanItem of scanPlan) { @@ -709,7 +236,7 @@ export class DiffAnimationController { // Add lines if not already added if (scanItem.leftLine && !scanItem.preAdded) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'left', line: scanItem.leftLine, @@ -718,7 +245,7 @@ export class DiffAnimationController { } if (scanItem.rightLine && !scanItem.preAdded) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'right', line: scanItem.rightLine, @@ -730,11 +257,11 @@ export class DiffAnimationController { await new Promise((resolve) => setTimeout(resolve, 10)) // Scan the line - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'scanLine', leftIndex: scanItem.leftIndex, rightIndex: scanItem.rightIndex, - autoScroll: this.shouldAutoScroll.get(filePath) !== false, + autoScroll: this.webviewManager.shouldAutoScrollForFile(filePath), }) // Wait before next line @@ -744,7 +271,7 @@ export class DiffAnimationController { // Add any remaining lines after scan region for (let i = changedRegion.endLine + 1; i < leftLines.length || i < rightLines.length; i++) { if (i < leftLines.length) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'left', line: leftLines[i], @@ -752,7 +279,7 @@ export class DiffAnimationController { }) } if (i < rightLines.length) { - await webview.webview.postMessage({ + await this.webviewManager.sendMessageToWebview(filePath, { command: 'addLine', side: 'right', line: rightLines[i], @@ -762,26 +289,21 @@ export class DiffAnimationController { } // Complete animation - await webview.webview.postMessage({ command: 'completeScan' }) + await this.webviewManager.sendMessageToWebview(filePath, { command: 'completeScan' }) // Update animation history this.updateAnimationComplete(filePath, newContent) getLogger().info(`[DiffAnimationController] ✅ Smart scanning completed for: ${filePath}`) - // Auto-close after a delay if not from chat click // Auto-close after a delay if not from chat click if (!animation.isFromChatClick) { setTimeout(async () => { - this.closeDiffWebview(filePath) + this.webviewManager.closeDiffWebview(filePath) - // ADD THIS: Optionally reopen the file in normal editor + // Optionally reopen the file in normal editor try { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) - await vscode.window.showTextDocument(doc, { - preview: false, - viewColumn: vscode.ViewColumn.One, - }) + await this.vscodeIntegration.openFileInEditor(filePath) getLogger().info(`[DiffAnimationController] Reopened file after animation: ${filePath}`) } catch (error) { getLogger().error(`[DiffAnimationController] Failed to reopen file: ${error}`) @@ -794,178 +316,13 @@ export class DiffAnimationController { } } - /** - * Create a smart scan plan based on changed regions - */ - private createScanPlan( - originalContent: string, - newContent: string, - changedRegion: { startLine: number; endLine: number; totalLines: number } - ): { - leftLines: Array - rightLines: Array - scanPlan: Array<{ - leftIndex: number | undefined - rightIndex: number | undefined - leftLine?: DiffLine & { index: number } - rightLine?: DiffLine & { index: number } - preAdded?: boolean - }> - } { - const changes = diffLines(originalContent, newContent) - const leftLines: Array = [] - const rightLines: Array = [] - const scanPlan: Array<{ - leftIndex: number | undefined - rightIndex: number | undefined - leftLine?: DiffLine & { index: number } - rightLine?: DiffLine & { index: number } - preAdded?: boolean - }> = [] - - let leftLineNum = 1 - let rightLineNum = 1 - let leftIndex = 0 - let rightIndex = 0 - - for (const change of changes) { - const lines = change.value.split('\n').filter((l) => l !== undefined) - if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) { - continue - } - - if (change.removed) { - // Removed lines only on left - for (const line of lines) { - const diffLine = { - type: 'removed' as const, - content: line, - lineNumber: leftLineNum, - oldLineNumber: leftLineNum++, - index: leftIndex, - leftLineNumber: leftLineNum - 1, - } - leftLines.push(diffLine) - - // Add to scan plan if in changed region - if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { - scanPlan.push({ - leftIndex: leftIndex, - rightIndex: undefined, - leftLine: diffLine, - }) - } - leftIndex++ - } - } else if (change.added) { - // Added lines only on right - for (const line of lines) { - const diffLine = { - type: 'added' as const, - content: line, - lineNumber: rightLineNum, - newLineNumber: rightLineNum++, - index: rightIndex, - rightLineNumber: rightLineNum - 1, - } - rightLines.push(diffLine) - - // Add to scan plan if in changed region - if (rightIndex >= changedRegion.startLine && rightIndex <= changedRegion.endLine) { - scanPlan.push({ - leftIndex: undefined, - rightIndex: rightIndex, - rightLine: diffLine, - }) - } - rightIndex++ - } - } else { - // Unchanged lines on both sides - for (const line of lines) { - const leftDiffLine = { - type: 'unchanged' as const, - content: line, - lineNumber: leftLineNum, - oldLineNumber: leftLineNum++, - index: leftIndex, - leftLineNumber: leftLineNum - 1, - } - - const rightDiffLine = { - type: 'unchanged' as const, - content: line, - lineNumber: rightLineNum, - newLineNumber: rightLineNum++, - index: rightIndex, - rightLineNumber: rightLineNum - 1, - } - - leftLines.push(leftDiffLine) - rightLines.push(rightDiffLine) - - // Add to scan plan if in changed region - if (leftIndex >= changedRegion.startLine && leftIndex <= changedRegion.endLine) { - scanPlan.push({ - leftIndex: leftIndex, - rightIndex: rightIndex, - leftLine: leftDiffLine, - rightLine: rightDiffLine, - }) - } - - leftIndex++ - rightIndex++ - } - } - } - - return { leftLines, rightLines, scanPlan } - } - /** * Show VS Code's built-in diff view (for file tab clicks) */ public async showVSCodeDiff(filePath: string, originalContent: string, newContent: string): Promise { - const fileName = path.basename(filePath) - - // Close all editors first (Issue #3) - await vscode.commands.executeCommand('workbench.action.closeAllEditors') - - // For new files, use empty content if original is empty - const leftContent = originalContent || '' - - // Create temporary file for original content with a unique scheme - const leftUri = vscode.Uri.from({ - scheme: 'amazon-q-diff-temp', - path: `${fileName}`, - query: `original=${Date.now()}`, // Add timestamp to make it unique - }) - - // Register a one-time content provider for this URI - const disposable = vscode.workspace.registerTextDocumentContentProvider('amazon-q-diff-temp', { - provideTextDocumentContent: (uri) => { - if (uri.toString() === leftUri.toString()) { - return leftContent - } - return '' - }, - }) - - try { - // Open diff view - const fileUri = vscode.Uri.file(filePath) - await vscode.commands.executeCommand( - 'vscode.diff', - leftUri, - fileUri, - `${fileName}: ${leftContent ? 'Original' : 'New File'} ↔ Current` - ) - } finally { - // Clean up the content provider after a delay - setTimeout(() => disposable.dispose(), 1000) - } + return this.vscodeIntegration.showVSCodeDiff(filePath, originalContent, newContent) } + /** * Show static diff view (reuse existing webview) */ @@ -994,17 +351,6 @@ export class DiffAnimationController { return this.startDiffAnimation(filePath, originalContent, newContent) } - /** - * Close diff webview for a file - */ - private closeDiffWebview(filePath: string): void { - const webview = this.diffWebviews.get(filePath) - if (webview) { - webview.dispose() - this.diffWebviews.delete(filePath) - } - } - /** * Cancel ongoing animation */ @@ -1031,13 +377,11 @@ export class DiffAnimationController { getLogger().info(`[DiffAnimationController] 🛑 Stopping animation for: ${filePath}`) this.cancelAnimation(filePath) - this.closeDiffWebview(filePath) + this.webviewManager.closeDiffWebview(filePath) this.activeAnimations.delete(filePath) this.fileSnapshots.delete(filePath) this.animationTimeouts.delete(filePath) - this.shouldAutoScroll.delete(filePath) - this.lastScrollPosition.delete(filePath) } /** @@ -1087,10 +431,13 @@ export class DiffAnimationController { getLogger().info('[DiffAnimationController] đŸ’Ĩ Disposing controller') this.stopAllAnimations() - // Close all webviews - for (const [_, webview] of this.diffWebviews) { - webview.dispose() - } - this.diffWebviews.clear() + // Dispose component managers + this.webviewManager.dispose() + + // Clear all maps + this.activeAnimations.clear() + this.fileAnimationHistory.clear() + this.animationTimeouts.clear() + this.fileSnapshots.clear() } } diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index 327608be08f..bf9c7d00b73 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -24,31 +24,10 @@ import * as path from 'path' import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' import { getLogger } from 'aws-core-vscode/shared' import { DiffAnimationController, PartialUpdateOptions } from './diffAnimationController' - -interface PendingFileWrite { - filePath: string - originalContent: string - toolUseId: string - timestamp: number - changeLocation?: { - startLine: number - endLine: number - startChar?: number - endChar?: number - } -} - -interface QueuedAnimation { - originalContent: string - newContent: string - toolUseId: string - changeLocation?: { - startLine: number - endLine: number - startChar?: number - endChar?: number - } -} +import { FileSystemManager } from './fileSystemManager' +import { ChatProcessor } from './chatProcessor' +import { AnimationQueueManager } from './animationQueueManager' +import { PendingFileWrite } from './types' export class DiffAnimationHandler implements vscode.Disposable { /** @@ -75,55 +54,25 @@ export class DiffAnimationHandler implements vscode.Disposable { */ private diffAnimationController: DiffAnimationController - private disposables: vscode.Disposable[] = [] + private fileSystemManager: FileSystemManager + private chatProcessor: ChatProcessor + private animationQueueManager: AnimationQueueManager // Track pending file writes by file path private pendingWrites = new Map() - // Track which files are being animated - private animatingFiles = new Set() - // Track processed messages to avoid duplicates - private processedMessages = new Set() - // File system watcher - private fileWatcher: vscode.FileSystemWatcher | undefined - // Animation queue for handling multiple changes - private animationQueue = new Map() constructor() { getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler with Cline-style diff view`) - this.diffAnimationController = new DiffAnimationController() - - // Set up file system watcher for all files - this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*') - - // Watch for file changes - this.fileWatcher.onDidChange(async (uri) => { - await this.handleFileChange(uri) - }) - - // Watch for file creation - this.fileWatcher.onDidCreate(async (uri) => { - await this.handleFileChange(uri) - }) - this.disposables.push(this.fileWatcher) - - // Also listen to text document changes for more immediate detection - const changeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument(async (event) => { - if (event.document.uri.scheme !== 'file' || event.contentChanges.length === 0) { - return - } - - // Skip if we're currently animating this file - if (this.animatingFiles.has(event.document.uri.fsPath)) { - return - } - - // Check if this is an external change (not from user typing) - if (event.reason === undefined) { - await this.handleFileChange(event.document.uri) - } - }) - this.disposables.push(changeTextDocumentDisposable) + // Initialize components + this.diffAnimationController = new DiffAnimationController() + this.fileSystemManager = new FileSystemManager(this.handleFileChange.bind(this)) + this.chatProcessor = new ChatProcessor(this.fileSystemManager, this.handleFileWritePreparation.bind(this)) + this.animationQueueManager = new AnimationQueueManager( + this.fileSystemManager, + this.animateFileChangeWithDiff.bind(this), + this.animatePartialFileChange.bind(this) + ) } /** @@ -159,141 +108,44 @@ export class DiffAnimationHandler implements vscode.Disposable { tabId: string, isPartialResult?: boolean ): Promise { - getLogger().info( - `[DiffAnimationHandler] 📨 Processing ChatResult for tab ${tabId}, isPartial: ${isPartialResult}` - ) - - try { - // Handle both ChatResult and ChatMessage types - if ('type' in chatResult && chatResult.type === 'tool') { - // This is a ChatMessage - await this.processChatMessage(chatResult as ChatMessage, tabId) - } else if ('additionalMessages' in chatResult && chatResult.additionalMessages) { - // This is a ChatResult with additional messages - for (const message of chatResult.additionalMessages) { - await this.processChatMessage(message, tabId) - } - } - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to process chat result: ${error}`) - } + return this.chatProcessor.processChatResult(chatResult, tabId, isPartialResult) } /** - * Process individual chat messages + * Process ChatUpdateParams */ - private async processChatMessage(message: ChatMessage, tabId: string): Promise { - if (!message.messageId) { - return - } - - // Deduplicate messages - const messageKey = `${message.messageId}_${message.type}` - if (this.processedMessages.has(messageKey)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Already processed message: ${messageKey}`) - return - } - this.processedMessages.add(messageKey) - - // Check for fsWrite tool preparation (when tool is about to execute) - if (message.type === 'tool' && message.messageId.startsWith('progress_')) { - await this.processFsWritePreparation(message, tabId) - } + public async processChatUpdate(params: ChatUpdateParams): Promise { + return this.chatProcessor.processChatUpdate(params) } /** - * Process fsWrite preparation - capture content BEFORE file is written + * Handle file write preparation callback */ - private async processFsWritePreparation(message: ChatMessage, tabId: string): Promise { - // Cast to any to access properties that might not be in the type definition - const messageAny = message as any - - const fileList = messageAny.header?.fileList - if (!fileList?.filePaths || fileList.filePaths.length === 0) { - return - } - - const fileName = fileList.filePaths[0] - const fileDetails = fileList.details?.[fileName] - - if (!fileDetails?.description) { - return - } - - const filePath = await this.resolveFilePath(fileDetails.description) - if (!filePath) { - return - } - - // Extract toolUseId from progress message - const toolUseId = message.messageId!.replace('progress_', '') - - getLogger().info(`[DiffAnimationHandler] đŸŽŦ Preparing for fsWrite: ${filePath} (toolUse: ${toolUseId})`) - + private async handleFileWritePreparation(pendingWrite: PendingFileWrite): Promise { // Check if we already have a pending write for this file - if (this.pendingWrites.has(filePath)) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Already have pending write for ${filePath}, skipping`) + if (this.pendingWrites.has(pendingWrite.filePath)) { + getLogger().warn( + `[DiffAnimationHandler] âš ī¸ Already have pending write for ${pendingWrite.filePath}, skipping` + ) return } - // Capture current content IMMEDIATELY before the write happens - let originalContent = '' - let fileExists = false - - try { - const uri = vscode.Uri.file(filePath) - const document = await vscode.workspace.openTextDocument(uri) - originalContent = document.getText() - fileExists = true - getLogger().info(`[DiffAnimationHandler] 📸 Captured existing content: ${originalContent.length} chars`) - } catch (error) { - // File doesn't exist yet - getLogger().info(`[DiffAnimationHandler] 🆕 File doesn't exist yet: ${filePath}`) - originalContent = '' - } - - // Store pending write info - this.pendingWrites.set(filePath, { - filePath, - originalContent, - toolUseId, - timestamp: Date.now(), - }) - - // Open/create the file to make it visible - try { - if (!fileExists) { - // Create directory if needed - const directory = path.dirname(filePath) - await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) - - // DON'T create the file yet - let the actual write create it - // This ensures we capture the transition from non-existent to new content - getLogger().info( - `[DiffAnimationHandler] 📁 Directory prepared, file will be created by write operation` - ) - } else { - // DON'T open the document visually - we'll use diff view instead - - getLogger().info(`[DiffAnimationHandler] 📄 File exists and is accessible: ${filePath}`) - } - getLogger().info(`[DiffAnimationHandler] ✅ File prepared: ${filePath}`) - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Failed to prepare file: ${error}`) - // Clean up on error - this.pendingWrites.delete(filePath) - } + // Store the pending write + this.pendingWrites.set(pendingWrite.filePath, pendingWrite) + getLogger().info(`[DiffAnimationHandler] 📝 Stored pending write for: ${pendingWrite.filePath}`) } - /** - * Handle file changes - this is where we detect the actual write - */ /** * Handle file changes - this is where we detect the actual write */ private async handleFileChange(uri: vscode.Uri): Promise { const filePath = uri.fsPath + // Skip if we're currently animating this file + if (this.animationQueueManager.isAnimating(filePath)) { + return + } + // Check if we have a pending write for this file const pendingWrite = this.pendingWrites.get(filePath) if (!pendingWrite) { @@ -310,8 +162,7 @@ export class DiffAnimationHandler implements vscode.Disposable { try { // Read the new content that was just written - const newContentBuffer = await vscode.workspace.fs.readFile(uri) - const newContent = Buffer.from(newContentBuffer).toString('utf8') + const newContent = await this.fileSystemManager.getCurrentFileContent(filePath) // Check if content actually changed if (pendingWrite.originalContent !== newContent) { @@ -320,30 +171,8 @@ export class DiffAnimationHandler implements vscode.Disposable { `original: ${pendingWrite.originalContent.length} chars, new: ${newContent.length} chars` ) - // Note: We do NOT restore the original content to the file - // The webview will show a virtual diff animation independently - // This avoids interfering with AI's file operations - getLogger().info(`[DiffAnimationHandler] 📝 File has new content, will show virtual diff animation`) - - // If already animating, queue the change - if (this.animatingFiles.has(filePath)) { - const queue = this.animationQueue.get(filePath) || [] - queue.push({ - originalContent: pendingWrite.originalContent, - newContent, - toolUseId: pendingWrite.toolUseId, - changeLocation: pendingWrite.changeLocation, - }) - this.animationQueue.set(filePath, queue) - getLogger().info( - `[DiffAnimationHandler] 📋 Queued animation for ${filePath} (queue size: ${queue.length})` - ) - return - } - - // Start animation with the captured new content - // The controller will apply the new content after animation completes - await this.startAnimation(filePath, pendingWrite, newContent) + // Start animation using the queue manager + await this.animationQueueManager.startAnimation(filePath, pendingWrite, newContent) } else { getLogger().info(`[DiffAnimationHandler] â„šī¸ No content change for: ${filePath}`) } @@ -353,84 +182,19 @@ export class DiffAnimationHandler implements vscode.Disposable { } /** - * Start animation and process queue - */ - private async startAnimation(filePath: string, pendingWrite: PendingFileWrite, newContent: string): Promise { - // Check if we have change location for partial update - if (pendingWrite.changeLocation) { - // Use partial animation for targeted changes - await this.animatePartialFileChange( - filePath, - pendingWrite.originalContent, - newContent, - pendingWrite.changeLocation, - pendingWrite.toolUseId - ) - } else { - // Use full file animation - await this.animateFileChangeWithDiff( - filePath, - pendingWrite.originalContent, - newContent, - pendingWrite.toolUseId - ) - } - - // Process queued animations - await this.processQueuedAnimations(filePath) - } - - /** - * Process queued animations for a file + * Check if we should show static diff for a file */ - private async processQueuedAnimations(filePath: string): Promise { - const queue = this.animationQueue.get(filePath) - if (!queue || queue.length === 0) { - return - } - - const next = queue.shift() - if (!next) { - return - } - - getLogger().info( - `[DiffAnimationHandler] đŸŽ¯ Processing queued animation for ${filePath} (${queue.length} remaining)` - ) - - // Use the current file content as the "original" for the next animation - const currentContent = await this.getCurrentFileContent(filePath) - - await this.startAnimation( - filePath, - { - filePath, - originalContent: currentContent, - toolUseId: next.toolUseId, - timestamp: Date.now(), - changeLocation: next.changeLocation, - }, - next.newContent - ) - } + public shouldShowStaticDiff(filePath: string, content: string): boolean { + // Always show static diff when called from chat click + // This method is primarily called when user clicks on file tabs in chat + const animation = this.diffAnimationController.getAnimationData(filePath) - /** - * Get current file content - */ - private async getCurrentFileContent(filePath: string): Promise { - try { - const uri = vscode.Uri.file(filePath) - const content = await vscode.workspace.fs.readFile(uri) - return Buffer.from(content).toString('utf8') - } catch { - return '' + // If we have animation data, we should show static diff + if (animation) { + return true } - } - /** - * Check if we should show static diff for a file - */ - public shouldShowStaticDiff(filePath: string, content: string): boolean { + // Check if the file has been animated before return this.diffAnimationController.shouldShowStaticDiff(filePath, content) } @@ -443,12 +207,6 @@ export class DiffAnimationHandler implements vscode.Disposable { newContent: string, toolUseId: string ): Promise { - if (this.animatingFiles.has(filePath)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Already animating: ${filePath}`) - return - } - - this.animatingFiles.add(filePath) const animationId = `${path.basename(filePath)}_${Date.now()}` getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting Cline-style diff animation ${animationId}`) @@ -470,7 +228,6 @@ export class DiffAnimationHandler implements vscode.Disposable { } catch (error) { getLogger().error(`[DiffAnimationHandler] ❌ Failed to animate ${animationId}: ${error}`) } finally { - this.animatingFiles.delete(filePath) getLogger().info(`[DiffAnimationHandler] 🏁 Animation ${animationId} completed`) } } @@ -485,12 +242,6 @@ export class DiffAnimationHandler implements vscode.Disposable { changeLocation: { startLine: number; endLine: number }, toolUseId: string ): Promise { - if (this.animatingFiles.has(filePath)) { - getLogger().info(`[DiffAnimationHandler] â­ī¸ Already animating: ${filePath}`) - return - } - - this.animatingFiles.add(filePath) const animationId = `${path.basename(filePath)}_partial_${Date.now()}` getLogger().info( @@ -519,7 +270,6 @@ export class DiffAnimationHandler implements vscode.Disposable { // Fall back to full animation await this.animateFileChangeWithDiff(filePath, originalContent, newContent, toolUseId) } finally { - this.animatingFiles.delete(filePath) getLogger().info(`[DiffAnimationHandler] 🏁 Animation ${animationId} completed`) } } @@ -536,12 +286,7 @@ export class DiffAnimationHandler implements vscode.Disposable { getLogger().info(`[DiffAnimationHandler] 🎨 Processing file diff for: ${params.originalFileUri}`) try { - const filePath = await this.normalizeFilePath(params.originalFileUri) - if (!filePath) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${params.originalFileUri}`) - return - } - + const filePath = await this.fileSystemManager.normalizeFilePath(params.originalFileUri) const originalContent = params.originalFileContent || '' const newContent = params.fileContent || '' @@ -565,9 +310,6 @@ export class DiffAnimationHandler implements vscode.Disposable { } } - /** - * Show static diff view for a file (when clicked from chat) - */ /** * Show static diff view for a file (when clicked from chat) */ @@ -575,11 +317,7 @@ export class DiffAnimationHandler implements vscode.Disposable { getLogger().info(`[DiffAnimationHandler] 👆 File clicked from chat: ${filePath}`) // Normalize the file path - const normalizedPath = await this.normalizeFilePath(filePath) - if (!normalizedPath) { - getLogger().warn(`[DiffAnimationHandler] âš ī¸ Could not normalize path for: ${filePath}`) - return - } + const normalizedPath = await this.fileSystemManager.normalizeFilePath(filePath) // Get animation data if it exists const animation = this.diffAnimationController.getAnimationData(normalizedPath) @@ -599,125 +337,15 @@ export class DiffAnimationHandler implements vscode.Disposable { } } - /** - * Process ChatUpdateParams - */ - public async processChatUpdate(params: ChatUpdateParams): Promise { - getLogger().info(`[DiffAnimationHandler] 🔄 Processing chat update for tab ${params.tabId}`) - - if (params.data?.messages) { - for (const message of params.data.messages) { - await this.processChatMessage(message, params.tabId) - } - } - } - - /** - * Resolve file path to absolute path - */ - private async resolveFilePath(filePath: string): Promise { - getLogger().info(`[DiffAnimationHandler] 🔍 Resolving file path: ${filePath}`) - - try { - // If already absolute, return as is - if (path.isAbsolute(filePath)) { - getLogger().info(`[DiffAnimationHandler] ✅ Path is already absolute: ${filePath}`) - return filePath - } - - // Try to resolve relative to workspace folders - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - getLogger().warn('[DiffAnimationHandler] âš ī¸ No workspace folders found') - return filePath - } - - // Try each workspace folder - for (const folder of workspaceFolders) { - const absolutePath = path.join(folder.uri.fsPath, filePath) - getLogger().info(`[DiffAnimationHandler] 🔍 Trying: ${absolutePath}`) - - try { - await vscode.workspace.fs.stat(vscode.Uri.file(absolutePath)) - getLogger().info(`[DiffAnimationHandler] ✅ File exists at: ${absolutePath}`) - return absolutePath - } catch { - // File doesn't exist in this workspace folder, try next - } - } - - // If file doesn't exist yet, return path relative to first workspace - const defaultPath = path.join(workspaceFolders[0].uri.fsPath, filePath) - getLogger().info(`[DiffAnimationHandler] 🆕 Using default path for new file: ${defaultPath}`) - return defaultPath - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Error resolving file path: ${error}`) - return undefined - } - } - - /** - * Normalize file path from URI or path string - */ - private async normalizeFilePath(pathOrUri: string): Promise { - getLogger().info(`[DiffAnimationHandler] 🔧 Normalizing path: ${pathOrUri}`) - - try { - // Handle file:// protocol - if (pathOrUri.startsWith('file://')) { - const fsPath = vscode.Uri.parse(pathOrUri).fsPath - getLogger().info(`[DiffAnimationHandler] ✅ Converted file:// URI to: ${fsPath}`) - return fsPath - } - - // Check if it's already a file path - if (path.isAbsolute(pathOrUri)) { - getLogger().info(`[DiffAnimationHandler] ✅ Already absolute path: ${pathOrUri}`) - return pathOrUri - } - - // Try to parse as URI - try { - const uri = vscode.Uri.parse(pathOrUri) - if (uri.scheme === 'file') { - getLogger().info(`[DiffAnimationHandler] ✅ Parsed as file URI: ${uri.fsPath}`) - return uri.fsPath - } - } catch { - // Not a valid URI, treat as path - } - - // Return as-is if we can't normalize - getLogger().info(`[DiffAnimationHandler] âš ī¸ Using as-is: ${pathOrUri}`) - return pathOrUri - } catch (error) { - getLogger().error(`[DiffAnimationHandler] ❌ Error normalizing file path: ${error}`) - return pathOrUri - } - } - /** * Clear caches for a specific tab */ public clearTabCache(tabId: string): void { - // Clean up old pending writes (older than 5 minutes) - const now = Date.now() - const timeout = 5 * 60 * 1000 // 5 minutes - - let cleanedWrites = 0 - for (const [filePath, write] of this.pendingWrites) { - if (now - write.timestamp > timeout) { - this.pendingWrites.delete(filePath) - cleanedWrites++ - } - } + // Clean up old pending writes + const cleanedWrites = this.fileSystemManager.cleanupOldPendingWrites(this.pendingWrites) // Clear processed messages to prevent memory leak - if (this.processedMessages.size > 1000) { - const oldSize = this.processedMessages.size - this.processedMessages.clear() - getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${oldSize} processed messages`) - } + this.chatProcessor.clearProcessedMessages() if (cleanedWrites > 0) { getLogger().info(`[DiffAnimationHandler] 🧹 Cleared ${cleanedWrites} old pending writes`) @@ -729,21 +357,11 @@ export class DiffAnimationHandler implements vscode.Disposable { // Clear all tracking sets and maps this.pendingWrites.clear() - this.processedMessages.clear() - this.animatingFiles.clear() - this.animationQueue.clear() - // Dispose the diff animation controller + // Dispose components this.diffAnimationController.dispose() - - if (this.fileWatcher) { - this.fileWatcher.dispose() - } - - // Dispose all event listeners - for (const disposable of this.disposables) { - disposable.dispose() - } - this.disposables = [] + this.fileSystemManager.dispose() + this.animationQueueManager.clearAll() + this.chatProcessor.clearAll() } } diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/fileSystemManager.ts b/packages/amazonq/src/lsp/chat/diffAnimation/fileSystemManager.ts new file mode 100644 index 00000000000..2d3ab1891ec --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/fileSystemManager.ts @@ -0,0 +1,215 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { getLogger } from 'aws-core-vscode/shared' +import { PendingFileWrite } from './types' + +export class FileSystemManager implements vscode.Disposable { + private disposables: vscode.Disposable[] = [] + private fileWatcher: vscode.FileSystemWatcher | undefined + + constructor(private onFileChange: (uri: vscode.Uri) => Promise) { + this.setupFileWatcher() + } + + private setupFileWatcher(): void { + // Set up file system watcher for all files + this.fileWatcher = vscode.workspace.createFileSystemWatcher('**/*') + + // Watch for file changes + this.fileWatcher.onDidChange(async (uri) => { + await this.onFileChange(uri) + }) + + // Watch for file creation + this.fileWatcher.onDidCreate(async (uri) => { + await this.onFileChange(uri) + }) + + this.disposables.push(this.fileWatcher) + + // Also listen to text document changes for more immediate detection + const changeTextDocumentDisposable = vscode.workspace.onDidChangeTextDocument(async (event) => { + if (event.document.uri.scheme !== 'file' || event.contentChanges.length === 0) { + return + } + + // Check if this is an external change (not from user typing) + if (event.reason === undefined) { + await this.onFileChange(event.document.uri) + } + }) + this.disposables.push(changeTextDocumentDisposable) + } + + /** + * Resolve file path to absolute path + */ + public async resolveFilePath(filePath: string): Promise { + getLogger().info(`[FileSystemManager] 🔍 Resolving file path: ${filePath}`) + + try { + // If already absolute, return as is + if (path.isAbsolute(filePath)) { + getLogger().info(`[FileSystemManager] ✅ Path is already absolute: ${filePath}`) + return filePath + } + + // Try to resolve relative to workspace folders + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + getLogger().warn('[FileSystemManager] âš ī¸ No workspace folders found') + return filePath + } + + // Try each workspace folder + for (const folder of workspaceFolders) { + const absolutePath = path.join(folder.uri.fsPath, filePath) + getLogger().info(`[FileSystemManager] 🔍 Trying: ${absolutePath}`) + + try { + await vscode.workspace.fs.stat(vscode.Uri.file(absolutePath)) + getLogger().info(`[FileSystemManager] ✅ File exists at: ${absolutePath}`) + return absolutePath + } catch { + // File doesn't exist in this workspace folder, try next + } + } + + // If file doesn't exist yet, return path relative to first workspace + const defaultPath = path.join(workspaceFolders[0].uri.fsPath, filePath) + getLogger().info(`[FileSystemManager] 🆕 Using default path for new file: ${defaultPath}`) + return defaultPath + } catch (error) { + getLogger().error(`[FileSystemManager] ❌ Error resolving file path: ${error}`) + return undefined + } + } + + /** + * Normalize file path from URI or path string + */ + public async normalizeFilePath(pathOrUri: string): Promise { + getLogger().info(`[FileSystemManager] 🔧 Normalizing path: ${pathOrUri}`) + + try { + // Handle file:// protocol + if (pathOrUri.startsWith('file://')) { + const fsPath = vscode.Uri.parse(pathOrUri).fsPath + getLogger().info(`[FileSystemManager] ✅ Converted file:// URI to: ${fsPath}`) + return fsPath + } + + // Check if it's already a file path + if (path.isAbsolute(pathOrUri)) { + getLogger().info(`[FileSystemManager] ✅ Already absolute path: ${pathOrUri}`) + return pathOrUri + } + + // Try to parse as URI + try { + const uri = vscode.Uri.parse(pathOrUri) + if (uri.scheme === 'file') { + getLogger().info(`[FileSystemManager] ✅ Parsed as file URI: ${uri.fsPath}`) + return uri.fsPath + } + } catch { + // Not a valid URI, treat as path + } + + // Return as-is if we can't normalize + getLogger().info(`[FileSystemManager] âš ī¸ Using as-is: ${pathOrUri}`) + return pathOrUri + } catch (error) { + getLogger().error(`[FileSystemManager] ❌ Error normalizing file path: ${error}`) + return pathOrUri + } + } + + /** + * Capture current file content before modification + */ + public async captureFileContent(filePath: string): Promise<{ content: string; exists: boolean }> { + try { + const uri = vscode.Uri.file(filePath) + const document = await vscode.workspace.openTextDocument(uri) + const content = document.getText() + getLogger().info(`[FileSystemManager] 📸 Captured existing content: ${content.length} chars`) + return { content, exists: true } + } catch (error) { + // File doesn't exist yet + getLogger().info(`[FileSystemManager] 🆕 File doesn't exist yet: ${filePath}`) + return { content: '', exists: false } + } + } + + /** + * Prepare file for writing (create directory if needed) + */ + public async prepareFileForWrite(filePath: string, fileExists: boolean): Promise { + try { + if (!fileExists) { + // Create directory if needed + const directory = path.dirname(filePath) + await vscode.workspace.fs.createDirectory(vscode.Uri.file(directory)) + + getLogger().info(`[FileSystemManager] 📁 Directory prepared, file will be created by write operation`) + } else { + getLogger().info(`[FileSystemManager] 📄 File exists and is accessible: ${filePath}`) + } + getLogger().info(`[FileSystemManager] ✅ File prepared: ${filePath}`) + } catch (error) { + getLogger().error(`[FileSystemManager] ❌ Failed to prepare file: ${error}`) + throw error + } + } + + /** + * Read current file content + */ + public async getCurrentFileContent(filePath: string): Promise { + try { + const uri = vscode.Uri.file(filePath) + const content = await vscode.workspace.fs.readFile(uri) + return Buffer.from(content).toString('utf8') + } catch { + return '' + } + } + + /** + * Clean up old pending writes + */ + public cleanupOldPendingWrites(pendingWrites: Map): number { + const now = Date.now() + const timeout = 5 * 60 * 1000 // 5 minutes + + let cleanedWrites = 0 + for (const [filePath, write] of pendingWrites) { + if (now - write.timestamp > timeout) { + pendingWrites.delete(filePath) + cleanedWrites++ + } + } + + return cleanedWrites + } + + public dispose(): void { + getLogger().info(`[FileSystemManager] đŸ’Ĩ Disposing FileSystemManager`) + + if (this.fileWatcher) { + this.fileWatcher.dispose() + } + + // Dispose all event listeners + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables = [] + } +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/types.ts b/packages/amazonq/src/lsp/chat/diffAnimation/types.ts new file mode 100644 index 00000000000..b8f796fe4e4 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/types.ts @@ -0,0 +1,88 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' + +export interface PendingFileWrite { + filePath: string + originalContent: string + toolUseId: string + timestamp: number + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } +} + +export interface QueuedAnimation { + originalContent: string + newContent: string + toolUseId: string + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } +} + +export interface DiffAnimation { + uri: vscode.Uri + originalContent: string + newContent: string + isShowingStaticDiff?: boolean + animationCancelled?: boolean + isFromChatClick?: boolean +} + +export interface PartialUpdateOptions { + changeLocation?: { + startLine: number + endLine: number + startChar?: number + endChar?: number + } + searchContent?: string + isPartialUpdate?: boolean +} + +export interface DiffLine { + type: 'unchanged' | 'added' | 'removed' + content: string + lineNumber: number + oldLineNumber?: number + newLineNumber?: number +} + +export interface ChangedRegion { + startLine: number + endLine: number + totalLines: number +} + +export interface ScanPlan { + leftLines: Array + rightLines: Array + scanPlan: Array<{ + leftIndex: number | undefined + rightIndex: number | undefined + leftLine?: DiffLine & { index: number } + rightLine?: DiffLine & { index: number } + preAdded?: boolean + }> +} + +export interface WebviewMessage { + command: string + [key: string]: any +} + +export interface AnimationHistory { + lastAnimatedContent: string + animationCount: number + isCurrentlyAnimating: boolean +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/vscodeIntegration.ts b/packages/amazonq/src/lsp/chat/diffAnimation/vscodeIntegration.ts new file mode 100644 index 00000000000..137e05ba5ec --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/vscodeIntegration.ts @@ -0,0 +1,284 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { getLogger } from 'aws-core-vscode/shared' + +export class VSCodeIntegration { + constructor() { + getLogger().info('[VSCodeIntegration] 🚀 Initialized VS Code integration') + } + + /** + * Show VS Code's built-in diff view (for file tab clicks) + */ + public async showVSCodeDiff(filePath: string, originalContent: string, newContent: string): Promise { + const fileName = path.basename(filePath) + + // For new files, use empty content if original is empty + const leftContent = originalContent || '' + + // Create temporary file for original content with a unique scheme + const leftUri = vscode.Uri.from({ + scheme: 'amazon-q-diff-temp', + path: `${fileName}`, + query: `original=${Date.now()}`, // Add timestamp to make it unique + }) + + // Register a one-time content provider for this URI + const disposable = vscode.workspace.registerTextDocumentContentProvider('amazon-q-diff-temp', { + provideTextDocumentContent: (uri) => { + if (uri.toString() === leftUri.toString()) { + return leftContent + } + return '' + }, + }) + + try { + // Open diff view + const fileUri = vscode.Uri.file(filePath) + await vscode.commands.executeCommand( + 'vscode.diff', + leftUri, + fileUri, + `${fileName}: ${leftContent ? 'Original' : 'New File'} ↔ Current` + ) + } finally { + // Clean up the content provider after a delay + setTimeout(() => disposable.dispose(), 1000) + } + } + + /** + * Open a file in VS Code editor + */ + public async openFileInEditor(filePath: string, options?: vscode.TextDocumentShowOptions): Promise { + try { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + await vscode.window.showTextDocument(doc, { + preview: false, + viewColumn: vscode.ViewColumn.One, + ...options, + }) + getLogger().info(`[VSCodeIntegration] Opened file in editor: ${filePath}`) + } catch (error) { + getLogger().error(`[VSCodeIntegration] Failed to open file in editor: ${error}`) + throw error + } + } + + /** + * Show status bar message + */ + public showStatusMessage(message: string, timeout?: number): vscode.Disposable { + if (timeout !== undefined) { + return vscode.window.setStatusBarMessage(message, timeout) + } + return vscode.window.setStatusBarMessage(message) + } + + /** + * Show information message + */ + public async showInfoMessage(message: string, ...items: string[]): Promise { + return vscode.window.showInformationMessage(message, ...items) + } + + /** + * Show warning message + */ + public async showWarningMessage(message: string, ...items: string[]): Promise { + return vscode.window.showWarningMessage(message, ...items) + } + + /** + * Show error message + */ + public async showErrorMessage(message: string, ...items: string[]): Promise { + return vscode.window.showErrorMessage(message, ...items) + } + + /** + * Get workspace folders + */ + public getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] | undefined { + return vscode.workspace.workspaceFolders + } + + /** + * Get active text editor + */ + public getActiveTextEditor(): vscode.TextEditor | undefined { + return vscode.window.activeTextEditor + } + + /** + * Apply workspace edit + */ + public async applyWorkspaceEdit(edit: vscode.WorkspaceEdit): Promise { + return vscode.workspace.applyEdit(edit) + } + + /** + * Create workspace edit for file content replacement + */ + public createContentReplacementEdit(filePath: string, newContent: string): vscode.WorkspaceEdit { + const edit = new vscode.WorkspaceEdit() + const uri = vscode.Uri.file(filePath) + + // We'll need to get the document to determine the full range + // For now, create a simple edit that replaces everything + edit.createFile(uri, { ignoreIfExists: true }) + edit.insert(uri, new vscode.Position(0, 0), newContent) + + return edit + } + + /** + * Execute VS Code command + */ + public async executeCommand(command: string, ...args: any[]): Promise { + return vscode.commands.executeCommand(command, ...args) + } + + /** + * Register command + */ + public registerCommand(command: string, callback: (...args: any[]) => any): vscode.Disposable { + return vscode.commands.registerCommand(command, callback) + } + + /** + * Get configuration value + */ + public getConfiguration(section: string, defaultValue?: T): T { + const config = vscode.workspace.getConfiguration() + return config.get(section, defaultValue as T) + } + + /** + * Update configuration value + */ + public async updateConfiguration(section: string, value: any, target?: vscode.ConfigurationTarget): Promise { + const config = vscode.workspace.getConfiguration() + await config.update(section, value, target) + } + + /** + * Show quick pick + */ + public async showQuickPick( + items: T[] | Thenable, + options?: vscode.QuickPickOptions + ): Promise { + return vscode.window.showQuickPick(items, options) + } + + /** + * Show input box + */ + public async showInputBox(options?: vscode.InputBoxOptions): Promise { + return vscode.window.showInputBox(options) + } + + /** + * Create output channel + */ + public createOutputChannel(name: string): vscode.OutputChannel { + return vscode.window.createOutputChannel(name) + } + + /** + * Get file system stats + */ + public async getFileStat(uri: vscode.Uri): Promise { + return vscode.workspace.fs.stat(uri) + } + + /** + * Read file content + */ + public async readFile(uri: vscode.Uri): Promise { + return vscode.workspace.fs.readFile(uri) + } + + /** + * Write file content + */ + public async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + return vscode.workspace.fs.writeFile(uri, content) + } + + /** + * Create directory + */ + public async createDirectory(uri: vscode.Uri): Promise { + return vscode.workspace.fs.createDirectory(uri) + } + + /** + * Check if file exists + */ + public async fileExists(filePath: string): Promise { + try { + await this.getFileStat(vscode.Uri.file(filePath)) + return true + } catch { + return false + } + } + + /** + * Get relative path from workspace + */ + public getRelativePath(filePath: string): string { + const workspaceFolders = this.getWorkspaceFolders() + if (!workspaceFolders || workspaceFolders.length === 0) { + return filePath + } + + for (const folder of workspaceFolders) { + if (filePath.startsWith(folder.uri.fsPath)) { + return path.relative(folder.uri.fsPath, filePath) + } + } + + return filePath + } + + /** + * Focus on editor + */ + public async focusEditor(): Promise { + await this.executeCommand('workbench.action.focusActiveEditorGroup') + } + + /** + * Close all editors + */ + public async closeAllEditors(): Promise { + await this.executeCommand('workbench.action.closeAllEditors') + } + + /** + * Get theme information + */ + public getThemeInfo(): { + kind: vscode.ColorThemeKind + isDark: boolean + isLight: boolean + isHighContrast: boolean + } { + const kind = vscode.window.activeColorTheme.kind + return { + kind, + isDark: kind === vscode.ColorThemeKind.Dark, + isLight: kind === vscode.ColorThemeKind.Light, + isHighContrast: kind === vscode.ColorThemeKind.HighContrast, + } + } +} diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/webviewManager.ts b/packages/amazonq/src/lsp/chat/diffAnimation/webviewManager.ts new file mode 100644 index 00000000000..3085b68ae03 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/webviewManager.ts @@ -0,0 +1,465 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { getLogger } from 'aws-core-vscode/shared' +import { WebviewMessage } from './types' + +export class WebviewManager implements vscode.Disposable { + private diffWebviews = new Map() + // Auto-scroll control + private shouldAutoScroll = new Map() + private lastScrollPosition = new Map() + + constructor() { + getLogger().info('[WebviewManager] 🚀 Initialized webview manager') + } + + /** + * Get or create a webview panel for diff display + */ + public async getOrCreateDiffWebview(filePath: string): Promise { + // Check if we already have a webview for this file + let webview = this.diffWebviews.get(filePath) + if (webview) { + // Reveal existing webview + webview.reveal(vscode.ViewColumn.One) + return webview + } + + // Create new webview that will take over the editor area + const fileName = path.basename(filePath) + webview = vscode.window.createWebviewPanel('amazonQDiff', `Diff: ${fileName}`, vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [], + }) + + // Store webview + this.diffWebviews.set(filePath, webview) + + // Initialize scroll control + this.shouldAutoScroll.set(filePath, true) + this.lastScrollPosition.set(filePath, 0) + + // Handle webview disposal + webview.onDidDispose(() => { + this.diffWebviews.delete(filePath) + this.shouldAutoScroll.delete(filePath) + this.lastScrollPosition.delete(filePath) + // Reopen the original file editor after webview is disposed + Promise.resolve( + vscode.workspace.openTextDocument(vscode.Uri.file(filePath)).then((doc) => { + void vscode.window.showTextDocument(doc, { + preview: false, + viewColumn: vscode.ViewColumn.One, + }) + }) + ).catch((error) => { + getLogger().error(`[WebviewManager] Failed to reopen file after webview disposal: ${error}`) + }) + }) + + // Handle messages from webview (including scroll events) + webview.webview.onDidReceiveMessage((message) => { + this.handleWebviewMessage(filePath, message) + }) + + // Set initial HTML + webview.webview.html = this.getDiffWebviewContent() + + return webview + } + + /** + * Handle messages from webview + */ + private handleWebviewMessage(filePath: string, message: WebviewMessage): void { + if (message.command === 'userScrolled') { + const currentPosition = message.scrollTop + const lastPosition = this.lastScrollPosition.get(filePath) || 0 + + // If user scrolled up, disable auto-scroll + if (currentPosition < lastPosition - 50) { + // 50px threshold + this.shouldAutoScroll.set(filePath, false) + getLogger().info(`[WebviewManager] Auto-scroll disabled for: ${filePath}`) + } + + this.lastScrollPosition.set(filePath, currentPosition) + } + } + + /** + * Check if auto-scroll is enabled for a file + */ + public shouldAutoScrollForFile(filePath: string): boolean { + return this.shouldAutoScroll.get(filePath) !== false + } + + /** + * Send message to webview + */ + public async sendMessageToWebview(filePath: string, message: WebviewMessage): Promise { + const webview = this.diffWebviews.get(filePath) + if (webview) { + await webview.webview.postMessage(message) + } + } + + /** + * Close diff webview for a file + */ + public closeDiffWebview(filePath: string): void { + const webview = this.diffWebviews.get(filePath) + if (webview) { + webview.dispose() + this.diffWebviews.delete(filePath) + } + this.shouldAutoScroll.delete(filePath) + this.lastScrollPosition.delete(filePath) + } + + /** + * Get the HTML content for the diff webview + */ + private getDiffWebviewContent(): string { + return ` + + + + + Diff View + + + +
+
+
Original
+
+
Scanning changes...
+
+
+
+
AI's Changes
+
+
Scanning changes...
+
+
+
+ +
+ Scanning line 0 +
+ + + +` + } + + /** + * Get webview statistics + */ + public getWebviewStats(): { activeCount: number; filePaths: string[] } { + return { + activeCount: this.diffWebviews.size, + filePaths: Array.from(this.diffWebviews.keys()), + } + } + + public dispose(): void { + getLogger().info('[WebviewManager] đŸ’Ĩ Disposing webview manager') + + // Close all webviews + for (const [_, webview] of this.diffWebviews) { + webview.dispose() + } + this.diffWebviews.clear() + this.shouldAutoScroll.clear() + this.lastScrollPosition.clear() + } +} diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 993fa34e371..d6d19a66ff3 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -518,28 +518,25 @@ export function registerMessageListeners( ? vscode.Uri.parse(params.originalFileUri).fsPath : params.originalFileUri + const originalContent = params.originalFileContent || '' const newContent = params.fileContent || '' getLogger().info(`[VSCode Client] OpenFileDiff notification for: ${normalizedPath}`) + getLogger().info( + `[VSCode Client] Original content length: ${originalContent.length}, New content length: ${newContent.length}` + ) - // Check if we should show static diff - if (animationHandler.shouldShowStaticDiff(normalizedPath, newContent)) { - getLogger().info('[VSCode Client] From ChatClick, showing static diff') - await animationHandler.showStaticDiffForFile( - normalizedPath, - params.originalFileContent || '', - params.fileContent || '' - ) - } else { - getLogger().info('[VSCode Client] New content detected, starting animation') - // This is from chat click, pass the flag - await animationHandler.processFileDiff({ - originalFileUri: params.originalFileUri, - originalFileContent: params.originalFileContent || '', - fileContent: params.fileContent || '', - isFromChatClick: true, - }) - } + // For file tab clicks from chat, we should ALWAYS show the static diff view + // This is the key fix - don't rely on shouldShowStaticDiff logic for chat clicks + getLogger().info('[VSCode Client] File tab clicked from chat, showing static diff view') + + // Use processFileDiff with isFromChatClick=true, which will trigger showVSCodeDiff + await animationHandler.processFileDiff({ + originalFileUri: params.originalFileUri, + originalFileContent: originalContent, + fileContent: newContent, + isFromChatClick: true, // This ensures it goes to showVSCodeDiff + }) } catch (error) { // If animation fails, fall back to the original diff view getLogger().error(`[VSCode Client] Diff animation failed, falling back to standard diff view: ${error}`) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts new file mode 100644 index 00000000000..25da551a056 --- /dev/null +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -0,0 +1,620 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { + DiffAnimationController, + PartialUpdateOptions, +} from '../../../../../src/lsp/chat/diffAnimation/diffAnimationController' + +describe('DiffAnimationController', function () { + let controller: DiffAnimationController + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Mock vscode APIs + sandbox.stub(vscode.workspace, 'openTextDocument') + sandbox.stub(vscode.workspace, 'applyEdit') + sandbox.stub(vscode.window, 'showTextDocument') + sandbox.stub(vscode.commands, 'executeCommand') + sandbox.stub(vscode.window, 'setStatusBarMessage') + + // Mock vscode.workspace.fs properly + const mockFs = { + writeFile: sandbox.stub().resolves(), + readFile: sandbox.stub().resolves(Buffer.from('')), + stat: sandbox.stub().resolves({ type: vscode.FileType.File, ctime: 0, mtime: 0, size: 0 }), + } + sandbox.stub(vscode.workspace, 'fs').value(mockFs) + + controller = new DiffAnimationController() + }) + + afterEach(function () { + controller.dispose() + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize successfully', function () { + assert.ok(controller) + // Controller should be initialized without errors + }) + }) + + describe('getAnimationData', function () { + it('should return undefined for non-existent file', function () { + const result = controller.getAnimationData('/non/existent/file.js') + assert.strictEqual(result, undefined) + }) + + it('should return animation data after starting animation', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent) + + const result = controller.getAnimationData(filePath) + assert.ok(result) + assert.strictEqual(result.originalContent, originalContent) + assert.strictEqual(result.newContent, newContent) + }) + }) + + describe('shouldShowStaticDiff', function () { + it('should return false for new file without history', function () { + const result = controller.shouldShowStaticDiff('/new/file.js', 'content') + assert.strictEqual(result, false) + }) + + it('should return true when animation data exists', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent) + + const result = controller.shouldShowStaticDiff(filePath, newContent) + assert.strictEqual(result, true) + }) + }) + + describe('startDiffAnimation', function () { + it('should start animation for new file', async function () { + const filePath = '/test/new-file.js' + const originalContent = '' + const newContent = 'console.log("hello")' + + // Mock file operations for new file + ;(vscode.workspace.openTextDocument as sinon.SinonStub) + .onFirstCall() + .rejects(new Error('File not found')) + .onSecondCall() + .resolves({ + getText: () => '', + lineCount: 0, + save: sandbox.stub().resolves(), + }) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + // Should complete without errors + assert.ok(true) + }) + + it('should start animation for existing file', async function () { + const filePath = '/test/existing-file.js' + const originalContent = 'console.log("old")' + const newContent = 'console.log("new")' + + // Mock file operations for existing file + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + // Should complete without errors + assert.ok(true) + }) + + it('should handle chat click differently', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock VS Code diff command + ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() + + await controller.startDiffAnimation(filePath, originalContent, newContent, true) + + // Should handle chat click without errors + assert.ok(true) + }) + + it('should handle errors gracefully', async function () { + const filePath = '/test/error-file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock error in file operations + ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error('File error')) + + try { + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + } catch (error) { + // Expected to throw + } + + // Should handle errors gracefully + assert.ok(true) + }) + }) + + describe('startPartialDiffAnimation', function () { + it('should start partial animation with options', async function () { + const filePath = '/test/file.js' + const originalContent = 'line1\nline2\nline3' + const newContent = 'line1\nmodified line2\nline3' + const options: PartialUpdateOptions = { + changeLocation: { + startLine: 1, + endLine: 1, + }, + isPartialUpdate: true, + } + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 3, + lineAt: (line: number) => ({ text: `line${line + 1}` }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startPartialDiffAnimation(filePath, originalContent, newContent, options) + + // Should complete without errors + assert.ok(true) + }) + + it('should handle partial animation without options', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startPartialDiffAnimation(filePath, originalContent, newContent) + + // Should complete without errors + assert.ok(true) + }) + }) + + describe('showVSCodeDiff', function () { + it('should show VS Code diff view', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock VS Code diff command + ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() + + await controller.showVSCodeDiff(filePath, originalContent, newContent) + + // Should execute diff command + assert.ok(vscode.commands.executeCommand) + }) + + it('should handle errors in diff view', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock error in diff command + ;(vscode.commands.executeCommand as sinon.SinonStub).rejects(new Error('Diff error')) + + try { + await controller.showVSCodeDiff(filePath, originalContent, newContent) + } catch (error) { + // Expected to handle gracefully + } + + assert.ok(vscode.commands.executeCommand) + }) + }) + + describe('showStaticDiffView', function () { + it('should show static diff view for existing animation', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations and start animation first + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + await controller.showStaticDiffView(filePath) + + assert.ok(vscode.commands.executeCommand) + }) + + it('should handle missing animation data', async function () { + const filePath = '/test/non-existent-file.js' + + await controller.showStaticDiffView(filePath) + + // Should handle gracefully without errors + assert.ok(true) + }) + }) + + describe('stopDiffAnimation', function () { + it('should stop animation for specific file', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations and start animation + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + controller.stopDiffAnimation(filePath) + + // Animation data should be removed + const animationData = controller.getAnimationData(filePath) + assert.strictEqual(animationData, undefined) + }) + + it('should handle stopping non-existent animation', function () { + const filePath = '/test/non-existent.js' + + controller.stopDiffAnimation(filePath) + + // Should handle gracefully + assert.ok(true) + }) + }) + + describe('stopAllAnimations', function () { + it('should stop all active animations', async function () { + const filePath1 = '/test/file1.js' + const filePath2 = '/test/file2.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + // Start multiple animations + await controller.startDiffAnimation(filePath1, originalContent, newContent, false) + await controller.startDiffAnimation(filePath2, originalContent, newContent, false) + + controller.stopAllAnimations() + + // All animation data should be removed + assert.strictEqual(controller.getAnimationData(filePath1), undefined) + assert.strictEqual(controller.getAnimationData(filePath2), undefined) + }) + + it('should handle stopping when no animations are active', function () { + controller.stopAllAnimations() + + // Should handle gracefully + assert.ok(true) + }) + }) + + describe('isAnimating', function () { + it('should return false for non-existent file', function () { + const result = controller.isAnimating('/non/existent/file.js') + assert.strictEqual(result, false) + }) + + it('should return true for active animation', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + const result = controller.isAnimating(filePath) + assert.strictEqual(result, true) + }) + + it('should return false after stopping animation', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + controller.stopDiffAnimation(filePath) + + const result = controller.isAnimating(filePath) + assert.strictEqual(result, false) + }) + }) + + describe('isShowingStaticDiff', function () { + it('should return false for non-existent file', function () { + const result = controller.isShowingStaticDiff('/non/existent/file.js') + assert.strictEqual(result, false) + }) + + it('should return correct static diff status', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + const result = controller.isShowingStaticDiff(filePath) + assert.strictEqual(typeof result, 'boolean') + }) + }) + + describe('getAnimationStats', function () { + it('should return empty stats initially', function () { + const stats = controller.getAnimationStats() + assert.strictEqual(stats.activeCount, 0) + assert.deepStrictEqual(stats.filePaths, []) + }) + + it('should return correct stats with active animations', async function () { + const filePath1 = '/test/file1.js' + const filePath2 = '/test/file2.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath1, originalContent, newContent, false) + await controller.startDiffAnimation(filePath2, originalContent, newContent, false) + + const stats = controller.getAnimationStats() + assert.strictEqual(stats.activeCount, 2) + assert.ok(stats.filePaths.includes(filePath1)) + assert.ok(stats.filePaths.includes(filePath2)) + }) + }) + + describe('dispose', function () { + it('should dispose successfully', function () { + controller.dispose() + + // Should dispose without errors + assert.ok(true) + }) + + it('should stop all animations on dispose', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + controller.dispose() + + // Animation should be stopped + const stats = controller.getAnimationStats() + assert.strictEqual(stats.activeCount, 0) + }) + + it('should handle multiple dispose calls', function () { + controller.dispose() + controller.dispose() + + // Should not throw on multiple dispose calls + assert.ok(true) + }) + }) + + describe('edge cases', function () { + it('should handle very large content', async function () { + const filePath = '/test/large-file.js' + const originalContent = 'x'.repeat(100000) + const newContent = 'y'.repeat(100000) + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + // Should handle large content without errors + assert.ok(true) + }) + + it('should handle special characters in file paths', async function () { + const filePath = '/test/file with spaces & symbols!@#$.js' + const originalContent = 'original' + const newContent = 'new' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + const animationData = controller.getAnimationData(filePath) + assert.ok(animationData) + }) + + it('should handle empty content', async function () { + const filePath = '/test/empty-file.js' + const originalContent = '' + const newContent = '' + + // Mock file operations for empty file + ;(vscode.workspace.openTextDocument as sinon.SinonStub) + .onFirstCall() + .rejects(new Error('File not found')) + .onSecondCall() + .resolves({ + getText: () => '', + lineCount: 0, + save: sandbox.stub().resolves(), + }) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + // Should handle empty content without errors + assert.ok(true) + }) + + it('should handle concurrent animations on same file', async function () { + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent1 = 'new1' + const newContent2 = 'new2' + + // Mock file operations + const mockDoc = { + getText: () => originalContent, + lineCount: 1, + lineAt: () => ({ text: originalContent }), + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + + // Start concurrent animations + const promise1 = controller.startDiffAnimation(filePath, originalContent, newContent1, false) + const promise2 = controller.startDiffAnimation(filePath, originalContent, newContent2, false) + + await Promise.all([promise1, promise2]) + + // Should handle gracefully + const animationData = controller.getAnimationData(filePath) + assert.ok(animationData) + }) + }) +}) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts new file mode 100644 index 00000000000..adf5da312cb --- /dev/null +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts @@ -0,0 +1,471 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import * as sinon from 'sinon' +import { DiffAnimationHandler } from '../../../../../src/lsp/chat/diffAnimation/diffAnimationHandler' +import { ChatResult, ChatMessage, ChatUpdateParams } from '@aws/language-server-runtimes/protocol' + +describe('DiffAnimationHandler', function () { + let handler: DiffAnimationHandler + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Mock workspace folders + const mockWorkspaceFolders = [ + { + uri: vscode.Uri.file('/test/workspace'), + name: 'test-workspace', + index: 0, + }, + ] + sandbox.stub(vscode.workspace, 'workspaceFolders').value(mockWorkspaceFolders) + + // Mock vscode APIs comprehensively + sandbox.stub(vscode.workspace, 'openTextDocument') + sandbox.stub(vscode.workspace, 'applyEdit') + sandbox.stub(vscode.window, 'showTextDocument') + sandbox.stub(vscode.window, 'setStatusBarMessage') + sandbox.stub(vscode.commands, 'executeCommand') + sandbox.stub(vscode.window, 'createWebviewPanel') + + // Mock vscode.workspace.fs properly + const mockFs = { + writeFile: sandbox.stub().resolves(), + readFile: sandbox.stub().resolves(Buffer.from('')), + stat: sandbox.stub().resolves({ type: vscode.FileType.File, ctime: 0, mtime: 0, size: 0 }), + } + sandbox.stub(vscode.workspace, 'fs').value(mockFs) + + // Mock vscode.Uri + sandbox.stub(vscode.Uri, 'file').callsFake((path: string) => ({ fsPath: path, path }) as any) + + handler = new DiffAnimationHandler() + }) + + afterEach(function () { + void handler.dispose() + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize successfully', function () { + assert.ok(handler) + // Handler should be initialized without errors + }) + }) + + describe('testAnimation', function () { + it('should run test animation without errors', async function () { + const mockDoc = { + getText: () => '', + lineCount: 0, + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + ;(vscode.window.setStatusBarMessage as sinon.SinonStub).returns({}) + + await handler.testAnimation() + + // Should complete without errors + assert.ok(true) + }) + + it('should handle errors gracefully during test animation', async function () { + ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error('Test error')) + + try { + await handler.testAnimation() + } catch (error) { + // Expected to throw + } + + // Should handle errors gracefully + assert.ok(true) + }) + }) + + describe('processChatResult', function () { + it('should process ChatResult successfully', async function () { + const chatResult = { + body: 'Test chat result', + } as ChatResult + + await handler.processChatResult(chatResult, 'test-tab-id') + + // Should not throw and should process without errors + assert.ok(true) + }) + + it('should process ChatMessage successfully', async function () { + const chatMessage = {} as ChatMessage + + await handler.processChatResult(chatMessage, 'test-tab-id') + + // Should not throw and should process without errors + assert.ok(true) + }) + + it('should handle partial results', async function () { + const chatResult: ChatResult = { + body: 'Partial result', + } + + await handler.processChatResult(chatResult, 'test-tab-id', true) + + // Should not throw and should process without errors + assert.ok(true) + }) + + it('should handle empty chat result', async function () { + const chatResult: ChatResult = { + body: '', + } + + await handler.processChatResult(chatResult, 'test-tab-id') + + // Should not throw + assert.ok(true) + }) + }) + + describe('processChatUpdate', function () { + it('should process ChatUpdateParams successfully', async function () { + const params: ChatUpdateParams = { + tabId: 'test-tab-id', + } + + await handler.processChatUpdate(params) + + // Should not throw + assert.ok(true) + }) + + it('should handle empty update params', async function () { + const params: ChatUpdateParams = { + tabId: 'test-tab-id', + } as any + + await handler.processChatUpdate(params) + + // Should not throw + assert.ok(true) + }) + }) + + describe('shouldShowStaticDiff', function () { + it('should return true when animation data exists', function () { + const filePath = '/test/file.js' + const content = 'test content' + + const result = handler.shouldShowStaticDiff(filePath, content) + + // Should return boolean + assert.strictEqual(typeof result, 'boolean') + }) + + it('should handle non-existent file paths', function () { + const filePath = '/non/existent/file.js' + const content = 'test content' + + const result = handler.shouldShowStaticDiff(filePath, content) + + assert.strictEqual(typeof result, 'boolean') + }) + + it('should handle empty content', function () { + const filePath = '/test/file.js' + const content = '' + + const result = handler.shouldShowStaticDiff(filePath, content) + + assert.strictEqual(typeof result, 'boolean') + }) + }) + + describe('processFileDiff', function () { + it('should process file diff with all parameters', async function () { + const params = { + originalFileUri: '/test/file.js', + originalFileContent: 'original content', + fileContent: 'new content', + isFromChatClick: false, + } + + await handler.processFileDiff(params) + + // Should process without errors + assert.ok(true) + }) + + it('should process file diff with minimal parameters', async function () { + const params = { + originalFileUri: '/test/file.js', + } + + await handler.processFileDiff(params) + + // Should process without errors + assert.ok(true) + }) + + it('should handle file diff from chat click', async function () { + const params = { + originalFileUri: '/test/file.js', + originalFileContent: 'original content', + fileContent: 'new content', + isFromChatClick: true, + } + + await handler.processFileDiff(params) + + // Should process without errors + assert.ok(true) + }) + + it('should handle identical content', async function () { + const params = { + originalFileUri: '/test/file.js', + originalFileContent: 'same content', + fileContent: 'same content', + isFromChatClick: false, + } + + await handler.processFileDiff(params) + + // Should process without errors + assert.ok(true) + }) + + it('should handle errors during file diff processing', async function () { + const params = { + originalFileUri: 'invalid://uri', + originalFileContent: 'content', + fileContent: 'content', + } + + await handler.processFileDiff(params) + + // Should handle error gracefully + assert.ok(true) + }) + }) + + describe('showStaticDiffForFile', function () { + it('should show static diff with provided content', async function () { + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves({}) + ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) + + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + await handler.showStaticDiffForFile(filePath, originalContent, newContent) + + // Should show diff without errors + assert.ok(true) + }) + + it('should show static diff without provided content', async function () { + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves({}) + ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) + + const filePath = '/test/file.js' + + try { + try { + await handler.showStaticDiffForFile(filePath) + } catch (error) { + // Expected to handle gracefully or throw + } + } catch (error) { + // Expected to handle gracefully or throw + } + + // Should show diff without errors + assert.ok(true) + }) + + it('should handle file opening errors', async function () { + ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error('File not found')) + + const filePath = '/non/existent/file.js' + + try { + await handler.showStaticDiffForFile(filePath) + } catch (error) { + // Expected to handle gracefully or throw + } + + // Should handle error gracefully + assert.ok(true) + }) + }) + + describe('clearTabCache', function () { + it('should clear tab cache successfully', function () { + const tabId = 'test-tab-id' + + handler.clearTabCache(tabId) + + // Should not throw + assert.ok(true) + }) + + it('should handle multiple cache clears', function () { + const tabId = 'test-tab-id' + + handler.clearTabCache(tabId) + handler.clearTabCache(tabId) + handler.clearTabCache('another-tab') + + // Should not throw + assert.ok(true) + }) + }) + + describe('dispose', function () { + it('should dispose successfully', async function () { + await handler.dispose() + + // Should dispose without errors + assert.ok(true) + }) + + it('should handle multiple dispose calls', async function () { + await handler.dispose() + await handler.dispose() + + // Should not throw on multiple dispose calls + assert.ok(true) + }) + }) + + describe('edge cases', function () { + it('should handle very large file content', async function () { + const largeContent = 'x'.repeat(100000) + const chatResult: ChatResult = { + body: largeContent, + } + + await handler.processChatResult(chatResult, 'test-tab-id') + + // Should handle large content without issues + assert.ok(true) + }) + + it('should handle special characters in file paths', async function () { + const specialPath = '/test/file with spaces & symbols!@#$.js' + + const result = handler.shouldShowStaticDiff(specialPath, 'content') + + assert.strictEqual(typeof result, 'boolean') + }) + + it('should handle concurrent operations', async function () { + const promises = [] + + for (let i = 0; i < 10; i++) { + const chatResult: ChatResult = { + body: `Test ${i}`, + } + promises.push(handler.processChatResult(chatResult, `tab-${i}`)) + } + + await Promise.all(promises) + + // Should handle concurrent operations + assert.ok(true) + }) + + it('should handle null and undefined inputs', async function () { + try { + await handler.processChatResult(undefined as any, 'test-tab') + } catch (error) { + // Expected to handle gracefully + } + + try { + await handler.processChatResult(undefined as any, 'test-tab') + } catch (error) { + // Expected to handle gracefully + } + + // Should not crash the test + assert.ok(true) + }) + + it('should handle empty tab IDs', async function () { + const chatResult: ChatResult = { + body: 'Test', + } + + await handler.processChatResult(chatResult, '') + await handler.processChatResult(chatResult, undefined as any) + await handler.processChatResult(chatResult, undefined as any) + + // Should handle gracefully + assert.ok(true) + }) + }) + + describe('integration scenarios', function () { + it('should handle file creation scenario', async function () { + const mockDoc = { + getText: () => '', + lineCount: 0, + save: sandbox.stub().resolves(), + } + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + ;(vscode.window.setStatusBarMessage as sinon.SinonStub).returns({}) + + const params = { + originalFileUri: '/test/new-file.js', + originalFileContent: '', + fileContent: 'console.log("new file")', + isFromChatClick: false, + } + + await handler.processFileDiff(params) + + // Should handle new file creation + assert.ok(true) + }) + + it('should handle file modification scenario', async function () { + const params = { + originalFileUri: '/test/existing-file.js', + originalFileContent: 'console.log("old")', + fileContent: 'console.log("new")', + isFromChatClick: false, + } + + await handler.processFileDiff(params) + + // Should handle file modification + assert.ok(true) + }) + + it('should handle file deletion scenario', async function () { + const params = { + originalFileUri: '/test/file-to-delete.js', + originalFileContent: 'console.log("delete me")', + fileContent: '', + isFromChatClick: false, + } + + await handler.processFileDiff(params) + + // Should handle file deletion + assert.ok(true) + }) + }) +}) From d4332f0df5ef870e4967b2b9188faa88f7dde0cf Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 00:09:33 -0700 Subject: [PATCH 15/32] FIx duplication error --- .../diffAnimationController.test.ts | 267 ++++-------------- .../diffAnimationHandler.test.ts | 174 +++--------- 2 files changed, 98 insertions(+), 343 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 25da551a056..a8290c0f17c 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -15,6 +15,40 @@ describe('DiffAnimationController', function () { let controller: DiffAnimationController let sandbox: sinon.SinonSandbox + // Helper function to create mock document + function createMockDocument(content: string, lineCount: number = 1) { + return { + getText: () => content, + lineCount, + lineAt: (line: number) => ({ text: content.split('\n')[line] || content }), + save: sandbox.stub().resolves(), + } + } + + // Helper function to setup standard file operation mocks + function setupStandardMocks(content: string = 'original') { + const mockDoc = createMockDocument(content) + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() + return mockDoc + } + + // Helper function to setup new file mocks + function setupNewFileMocks() { + ;(vscode.workspace.openTextDocument as sinon.SinonStub) + .onFirstCall() + .rejects(new Error('File not found')) + .onSecondCall() + .resolves(createMockDocument('', 0)) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + } + + // Helper function to setup error mocks + function setupErrorMocks(errorMessage: string = 'Test error') { + ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage)) + } + beforeEach(function () { sandbox = sinon.createSandbox() @@ -44,7 +78,6 @@ describe('DiffAnimationController', function () { describe('constructor', function () { it('should initialize successfully', function () { assert.ok(controller) - // Controller should be initialized without errors }) }) @@ -59,16 +92,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent) const result = controller.getAnimationData(filePath) @@ -89,16 +113,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent) const result = controller.shouldShowStaticDiff(filePath, newContent) @@ -112,21 +127,9 @@ describe('DiffAnimationController', function () { const originalContent = '' const newContent = 'console.log("hello")' - // Mock file operations for new file - ;(vscode.workspace.openTextDocument as sinon.SinonStub) - .onFirstCall() - .rejects(new Error('File not found')) - .onSecondCall() - .resolves({ - getText: () => '', - lineCount: 0, - save: sandbox.stub().resolves(), - }) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupNewFileMocks() await controller.startDiffAnimation(filePath, originalContent, newContent, false) - // Should complete without errors assert.ok(true) }) @@ -135,19 +138,9 @@ describe('DiffAnimationController', function () { const originalContent = 'console.log("old")' const newContent = 'console.log("new")' - // Mock file operations for existing file - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) - // Should complete without errors assert.ok(true) }) @@ -156,12 +149,9 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock VS Code diff command - ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() - + setupStandardMocks() await controller.startDiffAnimation(filePath, originalContent, newContent, true) - // Should handle chat click without errors assert.ok(true) }) @@ -170,8 +160,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock error in file operations - ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error('File error')) + setupErrorMocks('File error') try { await controller.startDiffAnimation(filePath, originalContent, newContent, false) @@ -179,7 +168,6 @@ describe('DiffAnimationController', function () { // Expected to throw } - // Should handle errors gracefully assert.ok(true) }) }) @@ -197,19 +185,9 @@ describe('DiffAnimationController', function () { isPartialUpdate: true, } - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 3, - lineAt: (line: number) => ({ text: `line${line + 1}` }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startPartialDiffAnimation(filePath, originalContent, newContent, options) - // Should complete without errors assert.ok(true) }) @@ -218,19 +196,9 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startPartialDiffAnimation(filePath, originalContent, newContent) - // Should complete without errors assert.ok(true) }) }) @@ -241,12 +209,9 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock VS Code diff command - ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() - + setupStandardMocks() await controller.showVSCodeDiff(filePath, originalContent, newContent) - // Should execute diff command assert.ok(vscode.commands.executeCommand) }) @@ -255,7 +220,6 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock error in diff command ;(vscode.commands.executeCommand as sinon.SinonStub).rejects(new Error('Diff error')) try { @@ -274,17 +238,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations and start animation first - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) await controller.showStaticDiffView(filePath) @@ -296,7 +250,6 @@ describe('DiffAnimationController', function () { await controller.showStaticDiffView(filePath) - // Should handle gracefully without errors assert.ok(true) }) }) @@ -307,21 +260,11 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations and start animation - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) controller.stopDiffAnimation(filePath) - // Animation data should be removed const animationData = controller.getAnimationData(filePath) assert.strictEqual(animationData, undefined) }) @@ -331,7 +274,6 @@ describe('DiffAnimationController', function () { controller.stopDiffAnimation(filePath) - // Should handle gracefully assert.ok(true) }) }) @@ -343,23 +285,13 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + setupStandardMocks(originalContent) - // Start multiple animations await controller.startDiffAnimation(filePath1, originalContent, newContent, false) await controller.startDiffAnimation(filePath2, originalContent, newContent, false) controller.stopAllAnimations() - // All animation data should be removed assert.strictEqual(controller.getAnimationData(filePath1), undefined) assert.strictEqual(controller.getAnimationData(filePath2), undefined) }) @@ -367,7 +299,6 @@ describe('DiffAnimationController', function () { it('should handle stopping when no animations are active', function () { controller.stopAllAnimations() - // Should handle gracefully assert.ok(true) }) }) @@ -383,16 +314,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) const result = controller.isAnimating(filePath) @@ -404,16 +326,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) controller.stopDiffAnimation(filePath) @@ -433,16 +346,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) const result = controller.isShowingStaticDiff(filePath) @@ -463,15 +367,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath1, originalContent, newContent, false) await controller.startDiffAnimation(filePath2, originalContent, newContent, false) @@ -487,7 +383,6 @@ describe('DiffAnimationController', function () { it('should dispose successfully', function () { controller.dispose() - // Should dispose without errors assert.ok(true) }) @@ -496,21 +391,11 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) controller.dispose() - // Animation should be stopped const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 0) }) @@ -519,7 +404,6 @@ describe('DiffAnimationController', function () { controller.dispose() controller.dispose() - // Should not throw on multiple dispose calls assert.ok(true) }) }) @@ -530,19 +414,9 @@ describe('DiffAnimationController', function () { const originalContent = 'x'.repeat(100000) const newContent = 'y'.repeat(100000) - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) - // Should handle large content without errors assert.ok(true) }) @@ -551,16 +425,7 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) const animationData = controller.getAnimationData(filePath) @@ -572,21 +437,9 @@ describe('DiffAnimationController', function () { const originalContent = '' const newContent = '' - // Mock file operations for empty file - ;(vscode.workspace.openTextDocument as sinon.SinonStub) - .onFirstCall() - .rejects(new Error('File not found')) - .onSecondCall() - .resolves({ - getText: () => '', - lineCount: 0, - save: sandbox.stub().resolves(), - }) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - + setupNewFileMocks() await controller.startDiffAnimation(filePath, originalContent, newContent, false) - // Should handle empty content without errors assert.ok(true) }) @@ -596,23 +449,13 @@ describe('DiffAnimationController', function () { const newContent1 = 'new1' const newContent2 = 'new2' - // Mock file operations - const mockDoc = { - getText: () => originalContent, - lineCount: 1, - lineAt: () => ({ text: originalContent }), - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + setupStandardMocks(originalContent) - // Start concurrent animations const promise1 = controller.startDiffAnimation(filePath, originalContent, newContent1, false) const promise2 = controller.startDiffAnimation(filePath, originalContent, newContent2, false) await Promise.all([promise1, promise2]) - // Should handle gracefully const animationData = controller.getAnimationData(filePath) assert.ok(animationData) }) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts index adf5da312cb..3465b6096d7 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationHandler.test.ts @@ -13,28 +13,18 @@ describe('DiffAnimationHandler', function () { let handler: DiffAnimationHandler let sandbox: sinon.SinonSandbox - beforeEach(function () { - sandbox = sinon.createSandbox() + // Helper function to create mock document - // Mock workspace folders - const mockWorkspaceFolders = [ - { - uri: vscode.Uri.file('/test/workspace'), - name: 'test-workspace', - index: 0, - }, - ] - sandbox.stub(vscode.workspace, 'workspaceFolders').value(mockWorkspaceFolders) - - // Mock vscode APIs comprehensively + // Helper function to setup standard VS Code mocks + function setupStandardMocks() { sandbox.stub(vscode.workspace, 'openTextDocument') - sandbox.stub(vscode.workspace, 'applyEdit') + sandbox.stub(vscode.workspace, 'applyEdit').resolves(true) sandbox.stub(vscode.window, 'showTextDocument') sandbox.stub(vscode.window, 'setStatusBarMessage') - sandbox.stub(vscode.commands, 'executeCommand') + sandbox.stub(vscode.commands, 'executeCommand').resolves() sandbox.stub(vscode.window, 'createWebviewPanel') - // Mock vscode.workspace.fs properly + // Mock vscode.workspace.fs const mockFs = { writeFile: sandbox.stub().resolves(), readFile: sandbox.stub().resolves(Buffer.from('')), @@ -44,7 +34,24 @@ describe('DiffAnimationHandler', function () { // Mock vscode.Uri sandbox.stub(vscode.Uri, 'file').callsFake((path: string) => ({ fsPath: path, path }) as any) + } + // Helper function to setup workspace folders + function setupWorkspaceFolders() { + const mockWorkspaceFolders = [ + { + uri: vscode.Uri.file('/test/workspace'), + name: 'test-workspace', + index: 0, + }, + ] + sandbox.stub(vscode.workspace, 'workspaceFolders').value(mockWorkspaceFolders) + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + setupWorkspaceFolders() + setupStandardMocks() handler = new DiffAnimationHandler() }) @@ -56,25 +63,12 @@ describe('DiffAnimationHandler', function () { describe('constructor', function () { it('should initialize successfully', function () { assert.ok(handler) - // Handler should be initialized without errors }) }) describe('testAnimation', function () { it('should run test animation without errors', async function () { - const mockDoc = { - getText: () => '', - lineCount: 0, - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - ;(vscode.window.setStatusBarMessage as sinon.SinonStub).returns({}) - await handler.testAnimation() - - // Should complete without errors assert.ok(true) }) @@ -84,23 +78,19 @@ describe('DiffAnimationHandler', function () { try { await handler.testAnimation() } catch (error) { - // Expected to throw + // Expected to handle gracefully } - // Should handle errors gracefully assert.ok(true) }) }) describe('processChatResult', function () { it('should process ChatResult successfully', async function () { - const chatResult = { - body: 'Test chat result', - } as ChatResult + const chatResult = { body: 'Test chat result' } as ChatResult await handler.processChatResult(chatResult, 'test-tab-id') - // Should not throw and should process without errors assert.ok(true) }) @@ -109,82 +99,59 @@ describe('DiffAnimationHandler', function () { await handler.processChatResult(chatMessage, 'test-tab-id') - // Should not throw and should process without errors assert.ok(true) }) it('should handle partial results', async function () { - const chatResult: ChatResult = { - body: 'Partial result', - } + const chatResult: ChatResult = { body: 'Partial result' } await handler.processChatResult(chatResult, 'test-tab-id', true) - // Should not throw and should process without errors assert.ok(true) }) it('should handle empty chat result', async function () { - const chatResult: ChatResult = { - body: '', - } + const chatResult: ChatResult = { body: '' } await handler.processChatResult(chatResult, 'test-tab-id') - // Should not throw assert.ok(true) }) }) describe('processChatUpdate', function () { it('should process ChatUpdateParams successfully', async function () { - const params: ChatUpdateParams = { - tabId: 'test-tab-id', - } + const params: ChatUpdateParams = { tabId: 'test-tab-id' } await handler.processChatUpdate(params) - // Should not throw assert.ok(true) }) it('should handle empty update params', async function () { - const params: ChatUpdateParams = { - tabId: 'test-tab-id', - } as any + const params: ChatUpdateParams = { tabId: 'test-tab-id' } as any await handler.processChatUpdate(params) - // Should not throw assert.ok(true) }) }) describe('shouldShowStaticDiff', function () { - it('should return true when animation data exists', function () { - const filePath = '/test/file.js' - const content = 'test content' - - const result = handler.shouldShowStaticDiff(filePath, content) + it('should return boolean for any file path', function () { + const result = handler.shouldShowStaticDiff('/test/file.js', 'test content') - // Should return boolean assert.strictEqual(typeof result, 'boolean') }) it('should handle non-existent file paths', function () { - const filePath = '/non/existent/file.js' - const content = 'test content' - - const result = handler.shouldShowStaticDiff(filePath, content) + const result = handler.shouldShowStaticDiff('/non/existent/file.js', 'test content') assert.strictEqual(typeof result, 'boolean') }) it('should handle empty content', function () { - const filePath = '/test/file.js' - const content = '' - - const result = handler.shouldShowStaticDiff(filePath, content) + const result = handler.shouldShowStaticDiff('/test/file.js', '') assert.strictEqual(typeof result, 'boolean') }) @@ -201,18 +168,14 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should process without errors assert.ok(true) }) it('should process file diff with minimal parameters', async function () { - const params = { - originalFileUri: '/test/file.js', - } + const params = { originalFileUri: '/test/file.js' } await handler.processFileDiff(params) - // Should process without errors assert.ok(true) }) @@ -226,7 +189,6 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should process without errors assert.ok(true) }) @@ -240,7 +202,6 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should process without errors assert.ok(true) }) @@ -253,43 +214,30 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should handle error gracefully assert.ok(true) }) }) describe('showStaticDiffForFile', function () { it('should show static diff with provided content', async function () { - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves({}) - ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) - const filePath = '/test/file.js' const originalContent = 'original' const newContent = 'new' await handler.showStaticDiffForFile(filePath, originalContent, newContent) - // Should show diff without errors assert.ok(true) }) it('should show static diff without provided content', async function () { - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves({}) - ;(vscode.window.showTextDocument as sinon.SinonStub).resolves({}) - const filePath = '/test/file.js' try { - try { - await handler.showStaticDiffForFile(filePath) - } catch (error) { - // Expected to handle gracefully or throw - } + await handler.showStaticDiffForFile(filePath) } catch (error) { - // Expected to handle gracefully or throw + // Expected to handle gracefully } - // Should show diff without errors assert.ok(true) }) @@ -301,10 +249,9 @@ describe('DiffAnimationHandler', function () { try { await handler.showStaticDiffForFile(filePath) } catch (error) { - // Expected to handle gracefully or throw + // Expected to handle gracefully } - // Should handle error gracefully assert.ok(true) }) }) @@ -315,18 +262,14 @@ describe('DiffAnimationHandler', function () { handler.clearTabCache(tabId) - // Should not throw assert.ok(true) }) it('should handle multiple cache clears', function () { - const tabId = 'test-tab-id' - - handler.clearTabCache(tabId) - handler.clearTabCache(tabId) + handler.clearTabCache('test-tab-id') + handler.clearTabCache('test-tab-id') handler.clearTabCache('another-tab') - // Should not throw assert.ok(true) }) }) @@ -335,7 +278,6 @@ describe('DiffAnimationHandler', function () { it('should dispose successfully', async function () { await handler.dispose() - // Should dispose without errors assert.ok(true) }) @@ -343,7 +285,6 @@ describe('DiffAnimationHandler', function () { await handler.dispose() await handler.dispose() - // Should not throw on multiple dispose calls assert.ok(true) }) }) @@ -351,17 +292,14 @@ describe('DiffAnimationHandler', function () { describe('edge cases', function () { it('should handle very large file content', async function () { const largeContent = 'x'.repeat(100000) - const chatResult: ChatResult = { - body: largeContent, - } + const chatResult: ChatResult = { body: largeContent } await handler.processChatResult(chatResult, 'test-tab-id') - // Should handle large content without issues assert.ok(true) }) - it('should handle special characters in file paths', async function () { + it('should handle special characters in file paths', function () { const specialPath = '/test/file with spaces & symbols!@#$.js' const result = handler.shouldShowStaticDiff(specialPath, 'content') @@ -373,15 +311,12 @@ describe('DiffAnimationHandler', function () { const promises = [] for (let i = 0; i < 10; i++) { - const chatResult: ChatResult = { - body: `Test ${i}`, - } + const chatResult: ChatResult = { body: `Test ${i}` } promises.push(handler.processChatResult(chatResult, `tab-${i}`)) } await Promise.all(promises) - // Should handle concurrent operations assert.ok(true) }) @@ -392,41 +327,21 @@ describe('DiffAnimationHandler', function () { // Expected to handle gracefully } - try { - await handler.processChatResult(undefined as any, 'test-tab') - } catch (error) { - // Expected to handle gracefully - } - - // Should not crash the test assert.ok(true) }) it('should handle empty tab IDs', async function () { - const chatResult: ChatResult = { - body: 'Test', - } + const chatResult: ChatResult = { body: 'Test' } await handler.processChatResult(chatResult, '') await handler.processChatResult(chatResult, undefined as any) - await handler.processChatResult(chatResult, undefined as any) - // Should handle gracefully assert.ok(true) }) }) describe('integration scenarios', function () { it('should handle file creation scenario', async function () { - const mockDoc = { - getText: () => '', - lineCount: 0, - save: sandbox.stub().resolves(), - } - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - ;(vscode.window.setStatusBarMessage as sinon.SinonStub).returns({}) - const params = { originalFileUri: '/test/new-file.js', originalFileContent: '', @@ -436,7 +351,6 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should handle new file creation assert.ok(true) }) @@ -450,7 +364,6 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should handle file modification assert.ok(true) }) @@ -464,7 +377,6 @@ describe('DiffAnimationHandler', function () { await handler.processFileDiff(params) - // Should handle file deletion assert.ok(true) }) }) From f1738dee9dd08f70a4d4aa24583284159e486e2d Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 00:15:12 -0700 Subject: [PATCH 16/32] Fix final duplicate in controller test - use helper function --- .../diffAnimationController.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index a8290c0f17c..c5b9acfd19e 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -49,6 +49,15 @@ describe('DiffAnimationController', function () { ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage)) } + // Helper function to setup animation and verify disposal + async function setupAnimationAndDispose(filePath: string, originalContent: string, newContent: string) { + setupStandardMocks(originalContent) + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + controller.dispose() + const stats = controller.getAnimationStats() + assert.strictEqual(stats.activeCount, 0) + } + beforeEach(function () { sandbox = sinon.createSandbox() @@ -387,17 +396,7 @@ describe('DiffAnimationController', function () { }) it('should stop all animations on dispose', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - controller.dispose() - - const stats = controller.getAnimationStats() - assert.strictEqual(stats.activeCount, 0) + await setupAnimationAndDispose('/test/file.js', 'original', 'new') }) it('should handle multiple dispose calls', function () { From d33e110f1ac20f8809e11b5ffc8129b830c2cbf4 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 00:18:28 -0700 Subject: [PATCH 17/32] Eliminate final duplicate with setupAnimationAndStop helper --- .../diffAnimationController.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index c5b9acfd19e..a15ddf58d91 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -58,6 +58,15 @@ describe('DiffAnimationController', function () { assert.strictEqual(stats.activeCount, 0) } + // Helper function to setup animation and stop it + async function setupAnimationAndStop(filePath: string, originalContent: string, newContent: string) { + setupStandardMocks(originalContent) + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + controller.stopDiffAnimation(filePath) + const animationData = controller.getAnimationData(filePath) + assert.strictEqual(animationData, undefined) + } + beforeEach(function () { sandbox = sinon.createSandbox() @@ -265,17 +274,7 @@ describe('DiffAnimationController', function () { describe('stopDiffAnimation', function () { it('should stop animation for specific file', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - controller.stopDiffAnimation(filePath) - - const animationData = controller.getAnimationData(filePath) - assert.strictEqual(animationData, undefined) + await setupAnimationAndStop('/test/file.js', 'original', 'new') }) it('should handle stopping non-existent animation', function () { From 3aebfd11c36197f2051f2246cdd69d785d776f13 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 00:28:28 -0700 Subject: [PATCH 18/32] fix duplicate issues --- .../diffAnimation/diffAnimationController.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index a15ddf58d91..bf668da59c2 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -49,10 +49,15 @@ describe('DiffAnimationController', function () { ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage)) } - // Helper function to setup animation and verify disposal - async function setupAnimationAndDispose(filePath: string, originalContent: string, newContent: string) { + // Helper function to setup animation + async function setupAnimation(filePath: string, originalContent: string, newContent: string) { setupStandardMocks(originalContent) await controller.startDiffAnimation(filePath, originalContent, newContent, false) + } + + // Helper function to setup animation and verify disposal + async function setupAnimationAndDispose(filePath: string, originalContent: string, newContent: string) { + await setupAnimation(filePath, originalContent, newContent) controller.dispose() const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 0) @@ -60,8 +65,7 @@ describe('DiffAnimationController', function () { // Helper function to setup animation and stop it async function setupAnimationAndStop(filePath: string, originalContent: string, newContent: string) { - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) + await setupAnimation(filePath, originalContent, newContent) controller.stopDiffAnimation(filePath) const animationData = controller.getAnimationData(filePath) assert.strictEqual(animationData, undefined) From 0652aa6c4686ab14ee3f9f6c430d34e4beca6d9e Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 00:33:34 -0700 Subject: [PATCH 19/32] Final duplicate elimination - refactor isAnimating test with helper --- .../chat/diffAnimation/diffAnimationController.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index bf668da59c2..89b2088b176 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -334,15 +334,9 @@ describe('DiffAnimationController', function () { }) it('should return false after stopping animation', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - controller.stopDiffAnimation(filePath) + await setupAnimationAndStop('/test/file.js', 'original', 'new') - const result = controller.isAnimating(filePath) + const result = controller.isAnimating('/test/file.js') assert.strictEqual(result, false) }) }) From ee1e101345e827c8557b282d54a096828a45d142 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:00:23 -0700 Subject: [PATCH 20/32] Eliminate final duplicate - use setupAnimationAndCheckStaticDiff helper --- .../diffAnimationController.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 89b2088b176..f313432975d 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -71,6 +71,13 @@ describe('DiffAnimationController', function () { assert.strictEqual(animationData, undefined) } + // Helper function to setup animation and check static diff status + async function setupAnimationAndCheckStaticDiff(filePath: string, originalContent: string, newContent: string) { + await setupAnimation(filePath, originalContent, newContent) + const result = controller.isShowingStaticDiff(filePath) + assert.strictEqual(typeof result, 'boolean') + } + beforeEach(function () { sandbox = sinon.createSandbox() @@ -348,15 +355,7 @@ describe('DiffAnimationController', function () { }) it('should return correct static diff status', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - const result = controller.isShowingStaticDiff(filePath) - assert.strictEqual(typeof result, 'boolean') + await setupAnimationAndCheckStaticDiff('/test/file.js', 'original', 'new') }) }) From 3e5c026c11c4cef419bffcaa5a77511b8e770206 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:10:29 -0700 Subject: [PATCH 21/32] fix an mock error in test --- .../diffAnimationController.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index f313432975d..e4928c6cbbb 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -10,6 +10,9 @@ import { DiffAnimationController, PartialUpdateOptions, } from '../../../../../src/lsp/chat/diffAnimation/diffAnimationController' +import { WebviewManager } from '../../../../../src/lsp/chat/diffAnimation/webviewManager' +import { DiffAnalyzer } from '../../../../../src/lsp/chat/diffAnimation/diffAnalyzer' +import { VSCodeIntegration } from '../../../../../src/lsp/chat/diffAnimation/vscodeIntegration' describe('DiffAnimationController', function () { let controller: DiffAnimationController @@ -87,6 +90,8 @@ describe('DiffAnimationController', function () { sandbox.stub(vscode.window, 'showTextDocument') sandbox.stub(vscode.commands, 'executeCommand') sandbox.stub(vscode.window, 'setStatusBarMessage') + sandbox.stub(vscode.window, 'createWebviewPanel') + sandbox.stub(vscode.workspace, 'registerTextDocumentContentProvider') // Mock vscode.workspace.fs properly const mockFs = { @@ -96,6 +101,42 @@ describe('DiffAnimationController', function () { } sandbox.stub(vscode.workspace, 'fs').value(mockFs) + // Mock the component classes to prevent real instantiation + const mockWebviewPanel = { + reveal: sandbox.stub(), + dispose: sandbox.stub(), + onDidDispose: sandbox.stub().returns({ dispose: sandbox.stub() }), + webview: { + html: '', + onDidReceiveMessage: sandbox.stub().returns({ dispose: sandbox.stub() }), + postMessage: sandbox.stub().resolves(), + }, + } + + sandbox.stub(WebviewManager.prototype, 'getOrCreateDiffWebview').resolves(mockWebviewPanel as any) + sandbox.stub(WebviewManager.prototype, 'sendMessageToWebview').resolves() + sandbox.stub(WebviewManager.prototype, 'shouldAutoScrollForFile').returns(true) + sandbox.stub(WebviewManager.prototype, 'closeDiffWebview') + sandbox.stub(WebviewManager.prototype, 'dispose') + + sandbox.stub(DiffAnalyzer.prototype, 'calculateChangedRegion').returns({ + startLine: 0, + endLine: 5, + totalLines: 10, + }) + sandbox.stub(DiffAnalyzer.prototype, 'createScanPlan').returns({ + leftLines: [], + rightLines: [], + scanPlan: [], + }) + sandbox.stub(DiffAnalyzer.prototype, 'calculateAnimationTiming').returns({ + scanDelay: 50, + totalDuration: 1000, + }) + + sandbox.stub(VSCodeIntegration.prototype, 'showVSCodeDiff').resolves() + sandbox.stub(VSCodeIntegration.prototype, 'openFileInEditor').resolves() + controller = new DiffAnimationController() }) From 82a8e1a5951fea679e94b7d8da8b0ea81a003354 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:18:47 -0700 Subject: [PATCH 22/32] fix a test duplication error --- .../diffAnimationController.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index e4928c6cbbb..af9c61d6b8b 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -74,13 +74,6 @@ describe('DiffAnimationController', function () { assert.strictEqual(animationData, undefined) } - // Helper function to setup animation and check static diff status - async function setupAnimationAndCheckStaticDiff(filePath: string, originalContent: string, newContent: string) { - await setupAnimation(filePath, originalContent, newContent) - const result = controller.isShowingStaticDiff(filePath) - assert.strictEqual(typeof result, 'boolean') - } - beforeEach(function () { sandbox = sinon.createSandbox() @@ -396,7 +389,15 @@ describe('DiffAnimationController', function () { }) it('should return correct static diff status', async function () { - await setupAnimationAndCheckStaticDiff('/test/file.js', 'original', 'new') + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + setupStandardMocks(originalContent) + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + const result = controller.isShowingStaticDiff(filePath) + assert.strictEqual(typeof result, 'boolean') }) }) From 25e8d35e5a6bf22a08a6bc87b1780d6e49e00530 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:28:11 -0700 Subject: [PATCH 23/32] fix a duplication error --- .../diffAnimationController.test.ts | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index af9c61d6b8b..2e067f428d0 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -58,22 +58,6 @@ describe('DiffAnimationController', function () { await controller.startDiffAnimation(filePath, originalContent, newContent, false) } - // Helper function to setup animation and verify disposal - async function setupAnimationAndDispose(filePath: string, originalContent: string, newContent: string) { - await setupAnimation(filePath, originalContent, newContent) - controller.dispose() - const stats = controller.getAnimationStats() - assert.strictEqual(stats.activeCount, 0) - } - - // Helper function to setup animation and stop it - async function setupAnimationAndStop(filePath: string, originalContent: string, newContent: string) { - await setupAnimation(filePath, originalContent, newContent) - controller.stopDiffAnimation(filePath) - const animationData = controller.getAnimationData(filePath) - assert.strictEqual(animationData, undefined) - } - beforeEach(function () { sandbox = sinon.createSandbox() @@ -319,7 +303,14 @@ describe('DiffAnimationController', function () { describe('stopDiffAnimation', function () { it('should stop animation for specific file', async function () { - await setupAnimationAndStop('/test/file.js', 'original', 'new') + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + await setupAnimation(filePath, originalContent, newContent) + controller.stopDiffAnimation(filePath) + const animationData = controller.getAnimationData(filePath) + assert.strictEqual(animationData, undefined) }) it('should handle stopping non-existent animation', function () { @@ -375,9 +366,16 @@ describe('DiffAnimationController', function () { }) it('should return false after stopping animation', async function () { - await setupAnimationAndStop('/test/file.js', 'original', 'new') + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + await setupAnimation(filePath, originalContent, newContent) + controller.stopDiffAnimation(filePath) + const animationData = controller.getAnimationData(filePath) + assert.strictEqual(animationData, undefined) - const result = controller.isAnimating('/test/file.js') + const result = controller.isAnimating(filePath) assert.strictEqual(result, false) }) }) @@ -434,7 +432,14 @@ describe('DiffAnimationController', function () { }) it('should stop all animations on dispose', async function () { - await setupAnimationAndDispose('/test/file.js', 'original', 'new') + const filePath = '/test/file.js' + const originalContent = 'original' + const newContent = 'new' + + await setupAnimation(filePath, originalContent, newContent) + controller.dispose() + const stats = controller.getAnimationStats() + assert.strictEqual(stats.activeCount, 0) }) it('should handle multiple dispose calls', function () { From 8e76fefce349e047b4b92f499b839846a7d20fa0 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:35:03 -0700 Subject: [PATCH 24/32] remove duplicate tests --- .../diffAnimation/diffAnimationController.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 2e067f428d0..2436269b1cf 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -364,20 +364,6 @@ describe('DiffAnimationController', function () { const result = controller.isAnimating(filePath) assert.strictEqual(result, true) }) - - it('should return false after stopping animation', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - await setupAnimation(filePath, originalContent, newContent) - controller.stopDiffAnimation(filePath) - const animationData = controller.getAnimationData(filePath) - assert.strictEqual(animationData, undefined) - - const result = controller.isAnimating(filePath) - assert.strictEqual(result, false) - }) }) describe('isShowingStaticDiff', function () { From 5bdef4b38c648ef5969f0a96cedeeda552f2a989 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:41:10 -0700 Subject: [PATCH 25/32] remove a duplicate test --- .../lsp/chat/diffAnimation/diffAnimationController.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 2436269b1cf..d7b17985631 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -422,7 +422,8 @@ describe('DiffAnimationController', function () { const originalContent = 'original' const newContent = 'new' - await setupAnimation(filePath, originalContent, newContent) + setupStandardMocks(originalContent) + await controller.startDiffAnimation(filePath, originalContent, newContent, false) controller.dispose() const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 0) From 8d4d9fe0c9cb04491daece63292969145dd364a7 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 10:48:32 -0700 Subject: [PATCH 26/32] remove a duplicate test --- .../diffAnimationController.test.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index d7b17985631..8e675ea9513 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -52,12 +52,6 @@ describe('DiffAnimationController', function () { ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage)) } - // Helper function to setup animation - async function setupAnimation(filePath: string, originalContent: string, newContent: string) { - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - } - beforeEach(function () { sandbox = sinon.createSandbox() @@ -303,14 +297,21 @@ describe('DiffAnimationController', function () { describe('stopDiffAnimation', function () { it('should stop animation for specific file', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' + const filePath = '/test/specific-file.js' + const originalContent = 'test content' + const newContent = 'modified content' - await setupAnimation(filePath, originalContent, newContent) + setupStandardMocks(originalContent) + await controller.startDiffAnimation(filePath, originalContent, newContent, false) + + // Verify animation is running + assert.strictEqual(controller.isAnimating(filePath), true) + + // Stop the animation controller.stopDiffAnimation(filePath) - const animationData = controller.getAnimationData(filePath) - assert.strictEqual(animationData, undefined) + + // Verify animation is stopped + assert.strictEqual(controller.isAnimating(filePath), false) }) it('should handle stopping non-existent animation', function () { From 878dfe8073640a7e07d8f907fde0d484582c3ac3 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 11:00:34 -0700 Subject: [PATCH 27/32] remove a duplicate test --- .../diffAnimationController.test.ts | 527 ++++++++---------- 1 file changed, 234 insertions(+), 293 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 8e675ea9513..2bdbf6b3a98 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -18,8 +18,30 @@ describe('DiffAnimationController', function () { let controller: DiffAnimationController let sandbox: sinon.SinonSandbox - // Helper function to create mock document - function createMockDocument(content: string, lineCount: number = 1) { + // Test data constants + const testPaths = { + existing: '/test/existing-file.js', + new: '/test/new-file.js', + nonexistent: '/test/non-existent.js', + specialChars: '/test/file with spaces & symbols!@#$.js', + large: '/test/large-file.js', + empty: '/test/empty-file.js', + multiple1: '/test/file1.js', + multiple2: '/test/file2.js', + } + + const testContent = { + original: 'console.log("original")', + new: 'console.log("new")', + multiline: 'line1\nline2\nline3', + multilineModified: 'line1\nmodified line2\nline3', + empty: '', + large: 'x'.repeat(100000), + largeNew: 'y'.repeat(100000), + } + + // Helper functions + function createMockDocument(content: string, lineCount: number = content.split('\n').length) { return { getText: () => content, lineCount, @@ -28,33 +50,7 @@ describe('DiffAnimationController', function () { } } - // Helper function to setup standard file operation mocks - function setupStandardMocks(content: string = 'original') { - const mockDoc = createMockDocument(content) - ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() - return mockDoc - } - - // Helper function to setup new file mocks - function setupNewFileMocks() { - ;(vscode.workspace.openTextDocument as sinon.SinonStub) - .onFirstCall() - .rejects(new Error('File not found')) - .onSecondCall() - .resolves(createMockDocument('', 0)) - ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) - } - - // Helper function to setup error mocks - function setupErrorMocks(errorMessage: string = 'Test error') { - ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage)) - } - - beforeEach(function () { - sandbox = sinon.createSandbox() - + function setupVSCodeMocks() { // Mock vscode APIs sandbox.stub(vscode.workspace, 'openTextDocument') sandbox.stub(vscode.workspace, 'applyEdit') @@ -64,15 +60,16 @@ describe('DiffAnimationController', function () { sandbox.stub(vscode.window, 'createWebviewPanel') sandbox.stub(vscode.workspace, 'registerTextDocumentContentProvider') - // Mock vscode.workspace.fs properly + // Mock vscode.workspace.fs const mockFs = { writeFile: sandbox.stub().resolves(), readFile: sandbox.stub().resolves(Buffer.from('')), stat: sandbox.stub().resolves({ type: vscode.FileType.File, ctime: 0, mtime: 0, size: 0 }), } sandbox.stub(vscode.workspace, 'fs').value(mockFs) + } - // Mock the component classes to prevent real instantiation + function setupComponentMocks() { const mockWebviewPanel = { reveal: sandbox.stub(), dispose: sandbox.stub(), @@ -107,7 +104,39 @@ describe('DiffAnimationController', function () { sandbox.stub(VSCodeIntegration.prototype, 'showVSCodeDiff').resolves() sandbox.stub(VSCodeIntegration.prototype, 'openFileInEditor').resolves() + } + function setupFileOperationMocks( + scenario: 'existing' | 'new' | 'error', + content: string = testContent.original, + errorMessage?: string + ) { + switch (scenario) { + case 'existing': { + const mockDoc = createMockDocument(content) + ;(vscode.workspace.openTextDocument as sinon.SinonStub).resolves(mockDoc) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + ;(vscode.commands.executeCommand as sinon.SinonStub).resolves() + return mockDoc + } + case 'new': + ;(vscode.workspace.openTextDocument as sinon.SinonStub) + .onFirstCall() + .rejects(new Error('File not found')) + .onSecondCall() + .resolves(createMockDocument('', 0)) + ;(vscode.workspace.applyEdit as sinon.SinonStub).resolves(true) + break + case 'error': + ;(vscode.workspace.openTextDocument as sinon.SinonStub).rejects(new Error(errorMessage || 'Test error')) + break + } + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + setupVSCodeMocks() + setupComponentMocks() controller = new DiffAnimationController() }) @@ -116,277 +145,215 @@ describe('DiffAnimationController', function () { sandbox.restore() }) - describe('constructor', function () { + describe('initialization', function () { it('should initialize successfully', function () { assert.ok(controller) }) }) - describe('getAnimationData', function () { + describe('animation data management', function () { it('should return undefined for non-existent file', function () { - const result = controller.getAnimationData('/non/existent/file.js') + const result = controller.getAnimationData(testPaths.nonexistent) assert.strictEqual(result, undefined) }) it('should return animation data after starting animation', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent) + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new) - const result = controller.getAnimationData(filePath) + const result = controller.getAnimationData(testPaths.existing) assert.ok(result) - assert.strictEqual(result.originalContent, originalContent) - assert.strictEqual(result.newContent, newContent) + assert.strictEqual(result.originalContent, testContent.original) + assert.strictEqual(result.newContent, testContent.new) + }) + + it('should handle multiple files independently', async function () { + setupFileOperationMocks('existing', testContent.original) + + await controller.startDiffAnimation(testPaths.multiple1, testContent.original, testContent.new) + await controller.startDiffAnimation(testPaths.multiple2, testContent.original, testContent.new) + + assert.ok(controller.getAnimationData(testPaths.multiple1)) + assert.ok(controller.getAnimationData(testPaths.multiple2)) }) }) - describe('shouldShowStaticDiff', function () { + describe('static diff detection', function () { it('should return false for new file without history', function () { - const result = controller.shouldShowStaticDiff('/new/file.js', 'content') + const result = controller.shouldShowStaticDiff(testPaths.new, testContent.new) assert.strictEqual(result, false) }) it('should return true when animation data exists', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new) - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent) - - const result = controller.shouldShowStaticDiff(filePath, newContent) + const result = controller.shouldShowStaticDiff(testPaths.existing, testContent.new) assert.strictEqual(result, true) }) }) - describe('startDiffAnimation', function () { - it('should start animation for new file', async function () { - const filePath = '/test/new-file.js' - const originalContent = '' - const newContent = 'console.log("hello")' - - setupNewFileMocks() - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - assert.ok(true) + describe('animation lifecycle', function () { + describe('startDiffAnimation', function () { + it('should start animation for new file', async function () { + setupFileOperationMocks('new') + await controller.startDiffAnimation(testPaths.new, testContent.empty, testContent.new, false) + assert.ok(controller.getAnimationData(testPaths.new)) + }) + + it('should start animation for existing file', async function () { + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) + assert.ok(controller.getAnimationData(testPaths.existing)) + }) + + it('should handle chat click parameter', async function () { + setupFileOperationMocks('existing') + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, true) + assert.ok(controller.getAnimationData(testPaths.existing)) + }) + + it('should handle file operation errors', async function () { + setupFileOperationMocks('error', '', 'File access denied') + + try { + await controller.startDiffAnimation( + testPaths.existing, + testContent.original, + testContent.new, + false + ) + } catch (error) { + // Expected to throw + } + // Should not have animation data on error + assert.strictEqual(controller.getAnimationData(testPaths.existing), undefined) + }) + }) + + describe('startPartialDiffAnimation', function () { + it('should start partial animation with options', async function () { + const options: PartialUpdateOptions = { + changeLocation: { startLine: 1, endLine: 1 }, + isPartialUpdate: true, + } + + setupFileOperationMocks('existing', testContent.multiline) + await controller.startPartialDiffAnimation( + testPaths.existing, + testContent.multiline, + testContent.multilineModified, + options + ) + + assert.ok(controller.getAnimationData(testPaths.existing)) + }) + + it('should handle partial animation without options', async function () { + setupFileOperationMocks('existing', testContent.original) + await controller.startPartialDiffAnimation(testPaths.existing, testContent.original, testContent.new) + assert.ok(controller.getAnimationData(testPaths.existing)) + }) + }) + + describe('stopDiffAnimation', function () { + it('should stop specific animation', async function () { + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) + + assert.strictEqual(controller.isAnimating(testPaths.existing), true) + controller.stopDiffAnimation(testPaths.existing) + assert.strictEqual(controller.isAnimating(testPaths.existing), false) + }) + + it('should handle stopping non-existent animation', function () { + controller.stopDiffAnimation(testPaths.nonexistent) + // Should not throw + assert.ok(true) + }) + }) + + describe('stopAllAnimations', function () { + it('should stop all active animations', async function () { + setupFileOperationMocks('existing', testContent.original) + + await controller.startDiffAnimation(testPaths.multiple1, testContent.original, testContent.new, false) + await controller.startDiffAnimation(testPaths.multiple2, testContent.original, testContent.new, false) + + controller.stopAllAnimations() + + assert.strictEqual(controller.getAnimationData(testPaths.multiple1), undefined) + assert.strictEqual(controller.getAnimationData(testPaths.multiple2), undefined) + }) + + it('should handle stopping when no animations are active', function () { + controller.stopAllAnimations() + assert.ok(true) + }) }) + }) - it('should start animation for existing file', async function () { - const filePath = '/test/existing-file.js' - const originalContent = 'console.log("old")' - const newContent = 'console.log("new")' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - assert.ok(true) - }) - - it('should handle chat click differently', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks() - await controller.startDiffAnimation(filePath, originalContent, newContent, true) - - assert.ok(true) + describe('animation status queries', function () { + it('should return false for non-existent file animation status', function () { + assert.strictEqual(controller.isAnimating(testPaths.nonexistent), false) }) - it('should handle errors gracefully', async function () { - const filePath = '/test/error-file.js' - const originalContent = 'original' - const newContent = 'new' - - setupErrorMocks('File error') - - try { - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - } catch (error) { - // Expected to throw - } + it('should return true for active animation', async function () { + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) - assert.ok(true) + assert.strictEqual(controller.isAnimating(testPaths.existing), true) }) - }) - - describe('startPartialDiffAnimation', function () { - it('should start partial animation with options', async function () { - const filePath = '/test/file.js' - const originalContent = 'line1\nline2\nline3' - const newContent = 'line1\nmodified line2\nline3' - const options: PartialUpdateOptions = { - changeLocation: { - startLine: 1, - endLine: 1, - }, - isPartialUpdate: true, - } - - setupStandardMocks(originalContent) - await controller.startPartialDiffAnimation(filePath, originalContent, newContent, options) - assert.ok(true) + it('should return false for non-existent static diff status', function () { + assert.strictEqual(controller.isShowingStaticDiff(testPaths.nonexistent), false) }) - it('should handle partial animation without options', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startPartialDiffAnimation(filePath, originalContent, newContent) + it('should return correct static diff status', async function () { + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) - assert.ok(true) + const result = controller.isShowingStaticDiff(testPaths.existing) + assert.strictEqual(typeof result, 'boolean') }) }) - describe('showVSCodeDiff', function () { + describe('diff view operations', function () { it('should show VS Code diff view', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks() - await controller.showVSCodeDiff(filePath, originalContent, newContent) + setupFileOperationMocks('existing') + await controller.showVSCodeDiff(testPaths.existing, testContent.original, testContent.new) - assert.ok(vscode.commands.executeCommand) + assert.ok((vscode.commands.executeCommand as sinon.SinonStub).called) }) - it('should handle errors in diff view', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - + it('should handle diff view errors gracefully', async function () { ;(vscode.commands.executeCommand as sinon.SinonStub).rejects(new Error('Diff error')) try { - await controller.showVSCodeDiff(filePath, originalContent, newContent) + await controller.showVSCodeDiff(testPaths.existing, testContent.original, testContent.new) } catch (error) { - // Expected to handle gracefully + // Should handle gracefully } - assert.ok(vscode.commands.executeCommand) + assert.ok((vscode.commands.executeCommand as sinon.SinonStub).called) }) - }) - describe('showStaticDiffView', function () { it('should show static diff view for existing animation', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - await controller.showStaticDiffView(filePath) + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) + await controller.showStaticDiffView(testPaths.existing) - assert.ok(vscode.commands.executeCommand) + assert.ok((vscode.commands.executeCommand as sinon.SinonStub).called) }) - it('should handle missing animation data', async function () { - const filePath = '/test/non-existent-file.js' - - await controller.showStaticDiffView(filePath) - + it('should handle missing animation data in static diff view', async function () { + await controller.showStaticDiffView(testPaths.nonexistent) + // Should not throw assert.ok(true) }) }) - describe('stopDiffAnimation', function () { - it('should stop animation for specific file', async function () { - const filePath = '/test/specific-file.js' - const originalContent = 'test content' - const newContent = 'modified content' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - // Verify animation is running - assert.strictEqual(controller.isAnimating(filePath), true) - - // Stop the animation - controller.stopDiffAnimation(filePath) - - // Verify animation is stopped - assert.strictEqual(controller.isAnimating(filePath), false) - }) - - it('should handle stopping non-existent animation', function () { - const filePath = '/test/non-existent.js' - - controller.stopDiffAnimation(filePath) - - assert.ok(true) - }) - }) - - describe('stopAllAnimations', function () { - it('should stop all active animations', async function () { - const filePath1 = '/test/file1.js' - const filePath2 = '/test/file2.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - - await controller.startDiffAnimation(filePath1, originalContent, newContent, false) - await controller.startDiffAnimation(filePath2, originalContent, newContent, false) - - controller.stopAllAnimations() - - assert.strictEqual(controller.getAnimationData(filePath1), undefined) - assert.strictEqual(controller.getAnimationData(filePath2), undefined) - }) - - it('should handle stopping when no animations are active', function () { - controller.stopAllAnimations() - - assert.ok(true) - }) - }) - - describe('isAnimating', function () { - it('should return false for non-existent file', function () { - const result = controller.isAnimating('/non/existent/file.js') - assert.strictEqual(result, false) - }) - - it('should return true for active animation', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - const result = controller.isAnimating(filePath) - assert.strictEqual(result, true) - }) - }) - - describe('isShowingStaticDiff', function () { - it('should return false for non-existent file', function () { - const result = controller.isShowingStaticDiff('/non/existent/file.js') - assert.strictEqual(result, false) - }) - - it('should return correct static diff status', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - const result = controller.isShowingStaticDiff(filePath) - assert.strictEqual(typeof result, 'boolean') - }) - }) - - describe('getAnimationStats', function () { + describe('statistics and monitoring', function () { it('should return empty stats initially', function () { const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 0) @@ -394,38 +361,30 @@ describe('DiffAnimationController', function () { }) it('should return correct stats with active animations', async function () { - const filePath1 = '/test/file1.js' - const filePath2 = '/test/file2.js' - const originalContent = 'original' - const newContent = 'new' + setupFileOperationMocks('existing', testContent.original) - setupStandardMocks(originalContent) - - await controller.startDiffAnimation(filePath1, originalContent, newContent, false) - await controller.startDiffAnimation(filePath2, originalContent, newContent, false) + await controller.startDiffAnimation(testPaths.multiple1, testContent.original, testContent.new, false) + await controller.startDiffAnimation(testPaths.multiple2, testContent.original, testContent.new, false) const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 2) - assert.ok(stats.filePaths.includes(filePath1)) - assert.ok(stats.filePaths.includes(filePath2)) + assert.ok(stats.filePaths.includes(testPaths.multiple1)) + assert.ok(stats.filePaths.includes(testPaths.multiple2)) }) }) - describe('dispose', function () { + describe('resource management', function () { it('should dispose successfully', function () { controller.dispose() - assert.ok(true) }) it('should stop all animations on dispose', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent = 'new' + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) controller.dispose() + const stats = controller.getAnimationStats() assert.strictEqual(stats.activeCount, 0) }) @@ -433,60 +392,42 @@ describe('DiffAnimationController', function () { it('should handle multiple dispose calls', function () { controller.dispose() controller.dispose() - assert.ok(true) }) }) - describe('edge cases', function () { + describe('edge cases and robustness', function () { it('should handle very large content', async function () { - const filePath = '/test/large-file.js' - const originalContent = 'x'.repeat(100000) - const newContent = 'y'.repeat(100000) - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) + setupFileOperationMocks('existing', testContent.large) + await controller.startDiffAnimation(testPaths.large, testContent.large, testContent.largeNew, false) - assert.ok(true) + assert.ok(controller.getAnimationData(testPaths.large)) }) it('should handle special characters in file paths', async function () { - const filePath = '/test/file with spaces & symbols!@#$.js' - const originalContent = 'original' - const newContent = 'new' - - setupStandardMocks(originalContent) - await controller.startDiffAnimation(filePath, originalContent, newContent, false) + setupFileOperationMocks('existing', testContent.original) + await controller.startDiffAnimation(testPaths.specialChars, testContent.original, testContent.new, false) - const animationData = controller.getAnimationData(filePath) + const animationData = controller.getAnimationData(testPaths.specialChars) assert.ok(animationData) }) it('should handle empty content', async function () { - const filePath = '/test/empty-file.js' - const originalContent = '' - const newContent = '' + setupFileOperationMocks('new') + await controller.startDiffAnimation(testPaths.empty, testContent.empty, testContent.empty, false) - setupNewFileMocks() - await controller.startDiffAnimation(filePath, originalContent, newContent, false) - - assert.ok(true) + assert.ok(controller.getAnimationData(testPaths.empty)) }) it('should handle concurrent animations on same file', async function () { - const filePath = '/test/file.js' - const originalContent = 'original' - const newContent1 = 'new1' - const newContent2 = 'new2' - - setupStandardMocks(originalContent) + setupFileOperationMocks('existing', testContent.original) - const promise1 = controller.startDiffAnimation(filePath, originalContent, newContent1, false) - const promise2 = controller.startDiffAnimation(filePath, originalContent, newContent2, false) + const promise1 = controller.startDiffAnimation(testPaths.existing, testContent.original, 'new1', false) + const promise2 = controller.startDiffAnimation(testPaths.existing, testContent.original, 'new2', false) await Promise.all([promise1, promise2]) - const animationData = controller.getAnimationData(filePath) + const animationData = controller.getAnimationData(testPaths.existing) assert.ok(animationData) }) }) From a8c02b56177393548b775984ce66935ff3f6495c Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 11:19:33 -0700 Subject: [PATCH 28/32] fix a test logic error --- .../diffAnimationController.test.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts index 2bdbf6b3a98..57bf2408465 100644 --- a/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts +++ b/packages/amazonq/test/unit/lsp/chat/diffAnimation/diffAnimationController.test.ts @@ -102,7 +102,10 @@ describe('DiffAnimationController', function () { totalDuration: 1000, }) - sandbox.stub(VSCodeIntegration.prototype, 'showVSCodeDiff').resolves() + // Mock VSCodeIntegration to call vscode.commands.executeCommand so tests can verify it + sandbox.stub(VSCodeIntegration.prototype, 'showVSCodeDiff').callsFake(async () => { + await (vscode.commands.executeCommand as sinon.SinonStub)('vscode.diff') + }) sandbox.stub(VSCodeIntegration.prototype, 'openFileInEditor').resolves() } @@ -152,12 +155,11 @@ describe('DiffAnimationController', function () { }) describe('animation data management', function () { - it('should return undefined for non-existent file', function () { - const result = controller.getAnimationData(testPaths.nonexistent) - assert.strictEqual(result, undefined) - }) + it('should handle animation data lifecycle', async function () { + // Should return undefined for non-existent file + assert.strictEqual(controller.getAnimationData(testPaths.nonexistent), undefined) - it('should return animation data after starting animation', async function () { + // Should return animation data after starting animation setupFileOperationMocks('existing', testContent.original) await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new) @@ -210,7 +212,13 @@ describe('DiffAnimationController', function () { it('should handle chat click parameter', async function () { setupFileOperationMocks('existing') await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, true) - assert.ok(controller.getAnimationData(testPaths.existing)) + + // When isFromChatClick is true, it calls showVSCodeDiff and returns early + // So no animation data should be stored + assert.strictEqual(controller.getAnimationData(testPaths.existing), undefined) + + // But vscode.commands.executeCommand should have been called + assert.ok((vscode.commands.executeCommand as sinon.SinonStub).called) }) it('should handle file operation errors', async function () { @@ -294,27 +302,18 @@ describe('DiffAnimationController', function () { }) describe('animation status queries', function () { - it('should return false for non-existent file animation status', function () { + it('should handle animation status queries', async function () { + // Should return false for non-existent files assert.strictEqual(controller.isAnimating(testPaths.nonexistent), false) - }) - - it('should return true for active animation', async function () { - setupFileOperationMocks('existing', testContent.original) - await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) - - assert.strictEqual(controller.isAnimating(testPaths.existing), true) - }) - - it('should return false for non-existent static diff status', function () { assert.strictEqual(controller.isShowingStaticDiff(testPaths.nonexistent), false) - }) - it('should return correct static diff status', async function () { + // Should return correct status for active animation setupFileOperationMocks('existing', testContent.original) await controller.startDiffAnimation(testPaths.existing, testContent.original, testContent.new, false) - const result = controller.isShowingStaticDiff(testPaths.existing) - assert.strictEqual(typeof result, 'boolean') + assert.strictEqual(controller.isAnimating(testPaths.existing), true) + const staticDiffResult = controller.isShowingStaticDiff(testPaths.existing) + assert.strictEqual(typeof staticDiffResult, 'boolean') }) }) From a52e0d2b46e4cd7f6b5a752d78bdeed6b35e661f Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 12:31:15 -0700 Subject: [PATCH 29/32] Remove unnecessary workspace file from decorations folder --- .../decorations/aws-toolkit-vscode.code-workspace | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace diff --git a/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace b/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace deleted file mode 100644 index 6087a16b3e9..00000000000 --- a/packages/amazonq/src/inlineChat/decorations/aws-toolkit-vscode.code-workspace +++ /dev/null @@ -1,7 +0,0 @@ -{ - "folders": [ - { - "path": "../../../../..", - }, - ], -} From e4c2449a1e04800ef968464a55b8a0113ee530a1 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 12:39:28 -0700 Subject: [PATCH 30/32] Revert workspace file to original state without language-servers path --- aws-toolkit-vscode.code-workspace | 3 --- 1 file changed, 3 deletions(-) diff --git a/aws-toolkit-vscode.code-workspace b/aws-toolkit-vscode.code-workspace index 479f9e8fd66..f03aafae2fe 100644 --- a/aws-toolkit-vscode.code-workspace +++ b/aws-toolkit-vscode.code-workspace @@ -12,9 +12,6 @@ { "path": "packages/amazonq", }, - { - "path": "../language-servers", - }, ], "settings": { "typescript.tsdk": "node_modules/typescript/lib", From ffdf088d9a64dcd7d223ca502b9e924f451ac3e5 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Mon, 16 Jun 2025 16:06:32 -0700 Subject: [PATCH 31/32] Add language-servers back to workspace --- aws-toolkit-vscode.code-workspace | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aws-toolkit-vscode.code-workspace b/aws-toolkit-vscode.code-workspace index f03aafae2fe..479f9e8fd66 100644 --- a/aws-toolkit-vscode.code-workspace +++ b/aws-toolkit-vscode.code-workspace @@ -12,6 +12,9 @@ { "path": "packages/amazonq", }, + { + "path": "../language-servers", + }, ], "settings": { "typescript.tsdk": "node_modules/typescript/lib", From 86dbf85674512a95d062ce56ef0a50d13a3bac52 Mon Sep 17 00:00:00 2001 From: Mingxuan Wang Date: Sun, 6 Jul 2025 10:50:12 -0700 Subject: [PATCH 32/32] realize chunk by chunk animaton --- .../diffAnimation/diffAnimationHandler.ts | 100 +++++ .../diffAnimation/streamingDiffController.ts | 385 ++++++++++++++++++ packages/amazonq/src/lsp/chat/messages.ts | 53 +++ 3 files changed, 538 insertions(+) create mode 100644 packages/amazonq/src/lsp/chat/diffAnimation/streamingDiffController.ts diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts index bf9c7d00b73..b8cdeafdedc 100644 --- a/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts +++ b/packages/amazonq/src/lsp/chat/diffAnimation/diffAnimationHandler.ts @@ -28,6 +28,7 @@ import { FileSystemManager } from './fileSystemManager' import { ChatProcessor } from './chatProcessor' import { AnimationQueueManager } from './animationQueueManager' import { PendingFileWrite } from './types' +import { StreamingDiffController } from './streamingDiffController' export class DiffAnimationHandler implements vscode.Disposable { /** @@ -57,10 +58,22 @@ export class DiffAnimationHandler implements vscode.Disposable { private fileSystemManager: FileSystemManager private chatProcessor: ChatProcessor private animationQueueManager: AnimationQueueManager + private streamingDiffController: StreamingDiffController // Track pending file writes by file path private pendingWrites = new Map() + // Track streaming diff sessions by tool use ID + private streamingSessions = new Map< + string, + { + toolUseId: string + filePath: string + originalContent: string + startTime: number + } + >() + constructor() { getLogger().info(`[DiffAnimationHandler] 🚀 Initializing DiffAnimationHandler with Cline-style diff view`) @@ -73,6 +86,7 @@ export class DiffAnimationHandler implements vscode.Disposable { this.animateFileChangeWithDiff.bind(this), this.animatePartialFileChange.bind(this) ) + this.streamingDiffController = new StreamingDiffController() } /** @@ -337,6 +351,92 @@ export class DiffAnimationHandler implements vscode.Disposable { } } + /** + * Start streaming diff session for a tool use (called when fsWrite/fsReplace is detected) + */ + public async startStreamingDiffSession(toolUseId: string, filePath: string): Promise { + getLogger().info(`[DiffAnimationHandler] đŸŽŦ Starting streaming diff session for ${toolUseId} at ${filePath}`) + + try { + // Read original content before any changes + const originalContent = await this.fileSystemManager.getCurrentFileContent(filePath).catch(() => '') + + // Store the streaming session + this.streamingSessions.set(toolUseId, { + toolUseId, + filePath, + originalContent, + startTime: Date.now(), + }) + + // Open the streaming diff view immediately + await this.streamingDiffController.openStreamingDiffView(toolUseId, filePath, originalContent) + + getLogger().info(`[DiffAnimationHandler] ✅ Streaming diff session started for ${toolUseId}`) + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to start streaming session for ${toolUseId}: ${error}`) + } + } + + /** + * Stream content updates to the diff view (called from language server) + */ + public async streamContentUpdate( + toolUseId: string, + partialContent: string, + isFinal: boolean = false + ): Promise { + const session = this.streamingSessions.get(toolUseId) + if (!session) { + getLogger().warn(`[DiffAnimationHandler] âš ī¸ No streaming session found for ${toolUseId}`) + return + } + + getLogger().info( + `[DiffAnimationHandler] ⚡ Streaming content update for ${toolUseId}: ${partialContent.length} chars (final: ${isFinal})` + ) + + try { + // Stream the content to the diff view + await this.streamingDiffController.streamContentUpdate(toolUseId, partialContent, isFinal) + + if (isFinal) { + // Calculate session duration + const duration = Date.now() - session.startTime + getLogger().info( + `[DiffAnimationHandler] 🏁 Streaming session completed for ${toolUseId} in ${duration}ms` + ) + + // Clean up the session + this.streamingSessions.delete(toolUseId) + } + } catch (error) { + getLogger().error(`[DiffAnimationHandler] ❌ Failed to stream content for ${toolUseId}: ${error}`) + } + } + + /** + * Check if a streaming session is active + */ + public isStreamingActive(toolUseId: string): boolean { + return this.streamingSessions.has(toolUseId) && this.streamingDiffController.isStreamingActive(toolUseId) + } + + /** + * Get streaming statistics for debugging + */ + public getStreamingStats(toolUseId: string): any { + const session = this.streamingSessions.get(toolUseId) + const streamingStats = this.streamingDiffController.getStreamingStats(toolUseId) + + return { + sessionExists: !!session, + sessionDuration: session ? Date.now() - session.startTime : 0, + filePath: session?.filePath, + ...streamingStats, + } + } + /** * Clear caches for a specific tab */ diff --git a/packages/amazonq/src/lsp/chat/diffAnimation/streamingDiffController.ts b/packages/amazonq/src/lsp/chat/diffAnimation/streamingDiffController.ts new file mode 100644 index 00000000000..497cfeefe47 --- /dev/null +++ b/packages/amazonq/src/lsp/chat/diffAnimation/streamingDiffController.ts @@ -0,0 +1,385 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import * as path from 'path' +import { getLogger } from 'aws-core-vscode/shared' + +export const diffViewUriScheme = 'amazonq-diff' + +/** + * Streaming Diff Controller using Cline's exact approach + * + * Opens VSCode's native diff view between original content (virtual) and actual file (real) + * Streams content directly to the actual file with yellow line animations + */ +export class StreamingDiffController implements vscode.Disposable { + private activeStreamingSessions = new Map< + string, + { + filePath: string + originalContent: string + activeDiffEditor: vscode.TextEditor + fadedOverlayController: DecorationController + activeLineController: DecorationController + streamedLines: string[] + disposed: boolean + } + >() + + private contentProvider: DiffContentProvider + + constructor() { + getLogger().info('[StreamingDiffController] 🚀 Initializing Cline-style streaming diff controller') + + // Register content provider for diff view (like Cline's approach) + this.contentProvider = new DiffContentProvider() + vscode.workspace.registerTextDocumentContentProvider(diffViewUriScheme, this.contentProvider) + } + + /** + * Opens diff view exactly like Cline: original content (virtual) vs actual file (real) + */ + async openStreamingDiffView(toolUseId: string, filePath: string, originalContent: string): Promise { + getLogger().info( + `[StreamingDiffController] đŸŽŦ Opening Cline-style diff view for ${filePath} (toolUse: ${toolUseId})` + ) + + try { + const fileName = path.basename(filePath) + const fileUri = vscode.Uri.file(filePath) + + // Create virtual URI for original content (like Cline's cline-diff: scheme) + const originalUri = vscode.Uri.parse(`${diffViewUriScheme}:${fileName}`).with({ + query: Buffer.from(originalContent).toString('base64'), + }) + + // Open the actual file first and ensure it exists + await this.ensureFileExists(filePath, originalContent) + + // Open VSCode's native diff view (original virtual vs actual file) + const activeDiffEditor = await new Promise((resolve, reject) => { + const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor && editor.document.uri.fsPath === filePath) { + disposable.dispose() + resolve(editor) + } + }) + + void vscode.commands.executeCommand( + 'vscode.diff', + originalUri, + fileUri, + `${fileName}: Original ↔ Amazon Q Changes (Streaming)`, + { preserveFocus: true } + ) + + // Timeout after 10 seconds + setTimeout(() => { + disposable.dispose() + reject(new Error('Failed to open diff editor within timeout')) + }, 10000) + }) + + // Initialize Cline-style decorations + const fadedOverlayController = new DecorationController('fadedOverlay', activeDiffEditor) + const activeLineController = new DecorationController('activeLine', activeDiffEditor) + + // Apply faded overlay to all lines initially (like Cline) + fadedOverlayController.addLines(0, activeDiffEditor.document.lineCount) + + // Store the streaming session + this.activeStreamingSessions.set(toolUseId, { + filePath, + originalContent, + activeDiffEditor, + fadedOverlayController, + activeLineController, + streamedLines: [], + disposed: false, + }) + + // Show status message + vscode.window.setStatusBarMessage(`đŸŽŦ Streaming changes for ${fileName}...`, 5000) + + getLogger().info(`[StreamingDiffController] ✅ Cline-style diff view opened successfully for ${toolUseId}`) + } catch (error) { + getLogger().error(`[StreamingDiffController] ❌ Failed to open diff view for ${toolUseId}: ${error}`) + throw error + } + } + + /** + * Stream content updates exactly like Cline - update the actual file directly + */ + async streamContentUpdate(toolUseId: string, partialContent: string, isFinal: boolean = false): Promise { + const session = this.activeStreamingSessions.get(toolUseId) + + if (!session || session.disposed) { + getLogger().warn(`[StreamingDiffController] âš ī¸ No active streaming session for ${toolUseId}`) + return + } + + getLogger().info( + `[StreamingDiffController] ⚡ Streaming update for ${toolUseId}: ${partialContent.length} chars (final: ${isFinal})` + ) + + try { + // Split content into lines like Cline + const accumulatedLines = partialContent.split('\n') + if (!isFinal) { + accumulatedLines.pop() // remove the last partial line only if it's not the final update + } + + const diffEditor = session.activeDiffEditor + const document = diffEditor.document + + if (!diffEditor || !document) { + throw new Error('User closed text editor, unable to edit file...') + } + + // Place cursor at the beginning like Cline + const beginningOfDocument = new vscode.Position(0, 0) + diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument) + + const currentLine = + session.streamedLines.length + (accumulatedLines.length - session.streamedLines.length) - 1 + + if (currentLine >= 0) { + // Replace content using WorkspaceEdit like Cline + const edit = new vscode.WorkspaceEdit() + const rangeToReplace = new vscode.Range(0, 0, currentLine + 1, 0) + const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join('\n') + '\n' + edit.replace(document.uri, rangeToReplace, contentToReplace) + await vscode.workspace.applyEdit(edit) + + // Update decorations exactly like Cline + session.activeLineController.setActiveLine(currentLine) + session.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount) + + // Scroll to show changes like Cline + this.scrollEditorToLine(diffEditor, currentLine) + } + + // Update streamed lines + session.streamedLines = accumulatedLines + + if (isFinal) { + getLogger().info(`[StreamingDiffController] 🏁 Final update applied for ${toolUseId}`) + + // Handle remaining lines if content is shorter + if (session.streamedLines.length < document.lineCount) { + const edit = new vscode.WorkspaceEdit() + edit.delete(document.uri, new vscode.Range(session.streamedLines.length, 0, document.lineCount, 0)) + await vscode.workspace.applyEdit(edit) + } + + // Clear decorations like Cline + session.fadedOverlayController.clear() + session.activeLineController.clear() + + vscode.window.setStatusBarMessage(`✅ Streaming complete for ${path.basename(session.filePath)}`, 3000) + } + } catch (error) { + getLogger().error(`[StreamingDiffController] ❌ Failed to stream content update for ${toolUseId}: ${error}`) + } + } + + /** + * Ensure the target file exists (create if needed) + */ + private async ensureFileExists(filePath: string, initialContent: string): Promise { + try { + // Check if file exists by trying to open it + await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + } catch { + // File doesn't exist, create it + const edit = new vscode.WorkspaceEdit() + edit.createFile(vscode.Uri.file(filePath), { overwrite: false }) + await vscode.workspace.applyEdit(edit) + + // Write initial content + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)) + const fullEdit = new vscode.WorkspaceEdit() + fullEdit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), initialContent) + await vscode.workspace.applyEdit(fullEdit) + + await document.save() + } + } + + /** + * Scroll editor to line like Cline + */ + private scrollEditorToLine(editor: vscode.TextEditor, line: number): void { + const scrollLine = line + 4 + editor.revealRange(new vscode.Range(scrollLine, 0, scrollLine, 0), vscode.TextEditorRevealType.InCenter) + } + + /** + * Checks if streaming is active + */ + isStreamingActive(toolUseId: string): boolean { + const session = this.activeStreamingSessions.get(toolUseId) + return session !== undefined && !session.disposed + } + + /** + * Get streaming stats + */ + getStreamingStats(toolUseId: string): { isActive: boolean; contentLength: number } | undefined { + const session = this.activeStreamingSessions.get(toolUseId) + if (!session) { + return undefined + } + + return { + isActive: this.isStreamingActive(toolUseId), + contentLength: session.streamedLines.join('\n').length, + } + } + + /** + * Close streaming session + */ + async closeDiffView(toolUseId: string): Promise { + const session = this.activeStreamingSessions.get(toolUseId) + if (!session) { + return + } + + getLogger().info(`[StreamingDiffController] đŸšĒ Closing streaming session for ${toolUseId}`) + + try { + session.disposed = true + session.fadedOverlayController.clear() + session.activeLineController.clear() + this.activeStreamingSessions.delete(toolUseId) + + getLogger().info(`[StreamingDiffController] ✅ Closed streaming session for ${toolUseId}`) + } catch (error) { + getLogger().error( + `[StreamingDiffController] ❌ Failed to close streaming session for ${toolUseId}: ${error}` + ) + } + } + + /** + * Dispose all resources + */ + dispose(): void { + getLogger().info(`[StreamingDiffController] đŸ’Ĩ Disposing streaming diff controller`) + + for (const [toolUseId, session] of this.activeStreamingSessions.entries()) { + try { + session.disposed = true + session.fadedOverlayController.clear() + session.activeLineController.clear() + } catch (error) { + getLogger().error(`[StreamingDiffController] ❌ Error disposing session ${toolUseId}: ${error}`) + } + } + + this.activeStreamingSessions.clear() + getLogger().info(`[StreamingDiffController] ✅ Disposed all streaming sessions`) + } +} + +/** + * Simple content provider like Cline's - returns original content from base64 query + */ +class DiffContentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri): string { + try { + // Decode base64 content from query like Cline + return Buffer.from(uri.query, 'base64').toString('utf8') + } catch { + return '' + } + } +} + +/** + * Decoration Controller exactly like Cline's implementation + */ +const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 255, 0, 0.1)', + opacity: '0.4', + isWholeLine: true, +}) + +const activeLineDecorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: 'rgba(255, 255, 0, 0.3)', + opacity: '1', + isWholeLine: true, + border: '1px solid rgba(255, 255, 0, 0.5)', +}) + +type DecorationType = 'fadedOverlay' | 'activeLine' + +class DecorationController { + private decorationType: DecorationType + private editor: vscode.TextEditor + private ranges: vscode.Range[] = [] + + constructor(decorationType: DecorationType, editor: vscode.TextEditor) { + this.decorationType = decorationType + this.editor = editor + } + + getDecoration() { + switch (this.decorationType) { + case 'fadedOverlay': + return fadedOverlayDecorationType + case 'activeLine': + return activeLineDecorationType + } + } + + addLines(startIndex: number, numLines: number) { + // Guard against invalid inputs + if (startIndex < 0 || numLines <= 0) { + return + } + + const lastRange = this.ranges[this.ranges.length - 1] + if (lastRange && lastRange.end.line === startIndex - 1) { + this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines)) + } else { + const endLine = startIndex + numLines - 1 + this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER)) + } + + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + clear() { + this.ranges = [] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + updateOverlayAfterLine(line: number, totalLines: number) { + // Remove any existing ranges that start at or after the current line + this.ranges = this.ranges.filter((range) => range.end.line < line) + + // Add a new range for all lines after the current line + if (line < totalLines - 1) { + this.ranges.push( + new vscode.Range( + new vscode.Position(line + 1, 0), + new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER) + ) + ) + } + + // Apply the updated decorations + this.editor.setDecorations(this.getDecoration(), this.ranges) + } + + setActiveLine(line: number) { + this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)] + this.editor.setDecorations(this.getDecoration(), this.ranges) + } +} diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index 1ddb9f4e7bd..caa7810e559 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -573,6 +573,39 @@ export function registerMessageListeners( }) languageClient.onNotification(chatUpdateNotificationType.method, async (params: ChatUpdateParams) => { + // Process streaming chunks for real-time diff animations + if ((params.data as any)?.streamingChunk) { + const streamingChunk = (params.data as any).streamingChunk + try { + getLogger().info( + `[VSCode Client] 🌊 Received streaming chunk for ${streamingChunk.toolUseId}: ${streamingChunk.content?.length || 0} chars (complete: ${streamingChunk.isComplete})` + ) + + // Process streaming chunk with DiffAnimationHandler + const animationHandler = getDiffAnimationHandler() + + // Check if this is the first chunk for this toolUseId - if so, initialize streaming session + if (!animationHandler.isStreamingActive(streamingChunk.toolUseId) && streamingChunk.filePath) { + getLogger().info( + `[VSCode Client] đŸŽŦ Initializing streaming session for ${streamingChunk.toolUseId} at ${streamingChunk.filePath}` + ) + await animationHandler.startStreamingDiffSession(streamingChunk.toolUseId, streamingChunk.filePath) + } + + await animationHandler.streamContentUpdate( + streamingChunk.toolUseId, + streamingChunk.content || '', + streamingChunk.isComplete || false + ) + + getLogger().info(`[VSCode Client] ✅ Streaming chunk processed successfully`) + } catch (error) { + getLogger().error(`[VSCode Client] ❌ Failed to process streaming chunk: ${error}`) + } + // Don't forward streaming chunks to the webview - they're handled by the animation handler + return + } + // Process chat updates for diff animations if (params.data?.messages) { for (const message of params.data.messages) { @@ -596,6 +629,26 @@ export function registerMessageListeners( params: params, }) }) + + // Handle streaming diff updates from language server + languageClient.onNotification( + 'aws/chat/streamingDiffUpdate', + async (params: { toolUseId: string; partialContent: string; isFinal: boolean }) => { + try { + getLogger().info( + `[VSCode Client] 📡 Received streaming diff update for ${params.toolUseId}: ${params.partialContent.length} chars (final: ${params.isFinal})` + ) + + // Get the DiffAnimationHandler and stream the update + const animationHandler = getDiffAnimationHandler() + await animationHandler.streamContentUpdate(params.toolUseId, params.partialContent, params.isFinal) + + getLogger().info(`[VSCode Client] ✅ Streaming diff update processed successfully`) + } catch (error) { + getLogger().error(`[VSCode Client] ❌ Failed to process streaming diff update: ${error}`) + } + } + ) } // Clean up on extension deactivation