diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index df8e160b..8296c64c 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -14,59 +14,22 @@ import { type JSONRPCNotification, } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; -import { RecentFilesManager } from './recent-files-manager.js'; +import { OpenFilesManager } from './open-files-manager.js'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; -const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit function sendIdeContextUpdateNotification( transport: StreamableHTTPServerTransport, log: (message: string) => void, - recentFilesManager: RecentFilesManager, + openFilesManager: OpenFilesManager, ) { - const editor = vscode.window.activeTextEditor; - const activeFile = - editor && editor.document.uri.scheme === 'file' - ? editor.document.uri.fsPath - : undefined; - - const selection = editor?.selection; - const cursor = selection - ? { - // This value is a zero-based index, but the vscode IDE is one-based. - line: selection.active.line + 1, - character: selection.active.character, - } - : undefined; - - let selectedText = editor?.document.getText(selection) ?? undefined; - if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { - selectedText = - selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; - } - - const openFiles = recentFilesManager.recentFiles.map((file) => { - const isActive = file.filePath === activeFile; - return { - path: file.filePath, - timestamp: file.timestamp, - isActive, - ...(isActive && { - cursor, - selectedText, - }), - }; - }); + const ideContext = openFilesManager.state; const notification: JSONRPCNotification = { jsonrpc: '2.0', method: 'ide/contextUpdate', - params: { - workspaceState: { - openFiles, - }, - }, + params: ideContext, }; log( `Sending IDE context update notification: ${JSON.stringify( @@ -97,13 +60,13 @@ export class IDEServer { app.use(express.json()); const mcpServer = createMcpServer(); - const recentFilesManager = new RecentFilesManager(context); - const onDidChangeSubscription = recentFilesManager.onDidChange(() => { + const openFilesManager = new OpenFilesManager(context); + const onDidChangeSubscription = openFilesManager.onDidChange(() => { for (const transport of Object.values(transports)) { sendIdeContextUpdateNotification( transport, this.log.bind(this), - recentFilesManager, + openFilesManager, ); } }); @@ -207,7 +170,7 @@ export class IDEServer { sendIdeContextUpdateNotification( transport, this.log.bind(this), - recentFilesManager, + openFilesManager, ); sessionsWithInitialNotification.add(sessionId); } diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts new file mode 100644 index 00000000..0b1ada82 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { OpenFilesManager, MAX_FILES } from './open-files-manager.js'; + +vi.mock('vscode', () => ({ + EventEmitter: vi.fn(() => { + const listeners: Array<(e: void) => unknown> = []; + return { + event: vi.fn((listener) => { + listeners.push(listener); + return { dispose: vi.fn() }; + }), + fire: vi.fn(() => { + listeners.forEach((listener) => listener(undefined)); + }), + dispose: vi.fn(), + }; + }), + window: { + onDidChangeActiveTextEditor: vi.fn(), + onDidChangeTextEditorSelection: vi.fn(), + }, + workspace: { + onDidDeleteFiles: vi.fn(), + onDidCloseTextDocument: vi.fn(), + onDidRenameFiles: vi.fn(), + }, + Uri: { + file: (path: string) => ({ + fsPath: path, + scheme: 'file', + }), + }, + TextEditorSelectionChangeKind: { + Mouse: 2, + }, +})); + +describe('OpenFilesManager', () => { + let context: vscode.ExtensionContext; + let onDidChangeActiveTextEditorListener: ( + editor: vscode.TextEditor | undefined, + ) => void; + let onDidChangeTextEditorSelectionListener: ( + e: vscode.TextEditorSelectionChangeEvent, + ) => void; + let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void; + let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void; + let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void; + + beforeEach(() => { + vi.useFakeTimers(); + + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation( + (listener) => { + onDidChangeActiveTextEditorListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation( + (listener) => { + onDidChangeTextEditorSelectionListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation( + (listener) => { + onDidDeleteFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation( + (listener) => { + onDidCloseTextDocumentListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation( + (listener) => { + onDidRenameFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + + context = { + subscriptions: [], + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + const getUri = (path: string) => + vscode.Uri.file(path) as unknown as vscode.Uri; + + const addFile = (uri: vscode.Uri) => { + onDidChangeActiveTextEditorListener({ + document: { + uri, + getText: () => '', + }, + selection: { + active: { line: 0, character: 0 }, + }, + } as unknown as vscode.TextEditor); + }; + + it('adds a file to the list', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('moves an existing file to the top', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('does not exceed the max number of files', async () => { + const manager = new OpenFilesManager(context); + for (let i = 0; i < MAX_FILES + 5; i++) { + const uri = getUri(`/test/file${i}.txt`); + addFile(uri); + } + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(MAX_FILES); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + `/test/file${MAX_FILES + 4}.txt`, + ); + expect(manager.state.workspaceState!.openFiles![MAX_FILES - 1].path).toBe( + `/test/file5.txt`, + ); + }); + + it('fires onDidChange when a file is added', async () => { + const manager = new OpenFilesManager(context); + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + const uri = getUri('/test/file1.txt'); + addFile(uri); + + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is closed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); + + it('fires onDidChange when a file is removed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + + onDidDeleteFilesListener({ files: [uri1] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange when a file is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidDeleteFilesListener({ files: [uri] }); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes multiple files when they are deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + const uri3 = getUri('/test/file3.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri3); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(3); + + onDidDeleteFilesListener({ files: [uri1, uri3] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange only once when adding an existing file', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('updates the file when it is renamed', async () => { + const manager = new OpenFilesManager(context); + const oldUri = getUri('/test/file1.txt'); + const newUri = getUri('/test/file2.txt'); + addFile(oldUri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + + onDidRenameFilesListener({ files: [{ oldUri, newUri }] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('adds a file when the active editor changes', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('updates the cursor position on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.cursor).toEqual({ line: 11, character: 20 }); + }); + + it('updates the selected text on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + // We need to override the mock for getText for this test + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue('selected text'), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe('selected text'); + expect(textEditor.document.getText).toHaveBeenCalledWith(selection); + }); + + it('truncates long selected text', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const longText = 'a'.repeat(20000); + const truncatedText = longText.substring(0, 16384) + '... [TRUNCATED]'; + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue(longText), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe(truncatedText); + }); + + it('deactivates the previously active file', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri: uri1, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + await vi.advanceTimersByTimeAsync(100); + + let file1 = manager.state.workspaceState!.openFiles![0]; + expect(file1.isActive).toBe(true); + expect(file1.cursor).toBeDefined(); + + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + + file1 = manager.state.workspaceState!.openFiles!.find( + (f) => f.path === '/test/file1.txt', + )!; + const file2 = manager.state.workspaceState!.openFiles![0]; + + expect(file1.isActive).toBe(false); + expect(file1.cursor).toBeUndefined(); + expect(file1.selectedText).toBeUndefined(); + expect(file2.path).toBe('/test/file2.txt'); + expect(file2.isActive).toBe(true); + }); + + it('ignores non-file URIs', async () => { + const manager = new OpenFilesManager(context); + const uri = { + fsPath: '/test/file1.txt', + scheme: 'untitled', + } as vscode.Uri; + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); +}); diff --git a/packages/vscode-ide-companion/src/open-files-manager.ts b/packages/vscode-ide-companion/src/open-files-manager.ts new file mode 100644 index 00000000..ffd1a568 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { File, IdeContext } from '@google/gemini-cli-core'; + +export const MAX_FILES = 10; +const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit + +/** + * Keeps track of the workspace state, including open files, cursor position, and selected text. + */ +export class OpenFilesManager { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + private debounceTimer: NodeJS.Timeout | undefined; + private openFiles: File[] = []; + + constructor(private readonly context: vscode.ExtensionContext) { + const editorWatcher = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + if (editor && this.isFileUri(editor.document.uri)) { + this.addOrMoveToFront(editor); + this.fireWithDebounce(); + } + }, + ); + + const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( + (event) => { + if (this.isFileUri(event.textEditor.document.uri)) { + this.updateActiveContext(event.textEditor); + this.fireWithDebounce(); + } + }, + ); + + const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { + if (this.isFileUri(document.uri)) { + this.remove(document.uri); + this.fireWithDebounce(); + } + }); + + const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { + for (const uri of event.files) { + if (this.isFileUri(uri)) { + this.remove(uri); + } + } + this.fireWithDebounce(); + }); + + const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { + for (const { oldUri, newUri } of event.files) { + if (this.isFileUri(oldUri)) { + if (this.isFileUri(newUri)) { + this.rename(oldUri, newUri); + } else { + // The file was renamed to a non-file URI, so we should remove it. + this.remove(oldUri); + } + } + } + this.fireWithDebounce(); + }); + + context.subscriptions.push( + editorWatcher, + selectionWatcher, + closeWatcher, + deleteWatcher, + renameWatcher, + ); + + // Just add current active file on start-up. + if ( + vscode.window.activeTextEditor && + this.isFileUri(vscode.window.activeTextEditor.document.uri) + ) { + this.addOrMoveToFront(vscode.window.activeTextEditor); + } + } + + private isFileUri(uri: vscode.Uri): boolean { + return uri.scheme === 'file'; + } + + private addOrMoveToFront(editor: vscode.TextEditor) { + // Deactivate previous active file + const currentActive = this.openFiles.find((f) => f.isActive); + if (currentActive) { + currentActive.isActive = false; + currentActive.cursor = undefined; + currentActive.selectedText = undefined; + } + + // Remove if it exists + const index = this.openFiles.findIndex( + (f) => f.path === editor.document.uri.fsPath, + ); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + + // Add to the front as active + this.openFiles.unshift({ + path: editor.document.uri.fsPath, + timestamp: Date.now(), + isActive: true, + }); + + // Enforce max length + if (this.openFiles.length > MAX_FILES) { + this.openFiles.pop(); + } + + this.updateActiveContext(editor); + } + + private remove(uri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === uri.fsPath); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + } + + private rename(oldUri: vscode.Uri, newUri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath); + if (index !== -1) { + this.openFiles[index].path = newUri.fsPath; + } + } + + private updateActiveContext(editor: vscode.TextEditor) { + const file = this.openFiles.find( + (f) => f.path === editor.document.uri.fsPath, + ); + if (!file || !file.isActive) { + return; + } + + file.cursor = editor.selection.active + ? { + line: editor.selection.active.line + 1, + character: editor.selection.active.character, + } + : undefined; + + let selectedText: string | undefined = + editor.document.getText(editor.selection) || undefined; + if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { + selectedText = + selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH) + '... [TRUNCATED]'; + } + file.selectedText = selectedText; + } + + private fireWithDebounce() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.onDidChangeEmitter.fire(); + }, 50); // 50ms + } + + get state(): IdeContext { + return { + workspaceState: { + openFiles: [...this.openFiles], + }, + }; + } +} diff --git a/packages/vscode-ide-companion/src/recent-files-manager.test.ts b/packages/vscode-ide-companion/src/recent-files-manager.test.ts deleted file mode 100644 index 9d56a10d..00000000 --- a/packages/vscode-ide-companion/src/recent-files-manager.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; -import { - RecentFilesManager, - MAX_FILES, - MAX_FILE_AGE_MINUTES, -} from './recent-files-manager.js'; - -vi.mock('vscode', () => ({ - EventEmitter: vi.fn(() => { - const listeners: Array<(e: void) => unknown> = []; - return { - event: vi.fn((listener) => { - listeners.push(listener); - return { dispose: vi.fn() }; - }), - fire: vi.fn(() => { - listeners.forEach((listener) => listener(undefined)); - }), - dispose: vi.fn(), - }; - }), - window: { - onDidChangeActiveTextEditor: vi.fn(), - onDidChangeTextEditorSelection: vi.fn(), - }, - workspace: { - onDidDeleteFiles: vi.fn(), - onDidCloseTextDocument: vi.fn(), - onDidRenameFiles: vi.fn(), - }, - Uri: { - file: (path: string) => ({ - fsPath: path, - scheme: 'file', - }), - }, -})); - -describe('RecentFilesManager', () => { - let context: vscode.ExtensionContext; - let onDidChangeActiveTextEditorListener: ( - editor: vscode.TextEditor | undefined, - ) => void; - let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void; - let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void; - let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void; - - beforeEach(() => { - vi.useFakeTimers(); - - vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation( - (listener) => { - onDidChangeActiveTextEditorListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation( - (listener) => { - onDidDeleteFilesListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation( - (listener) => { - onDidCloseTextDocumentListener = listener; - return { dispose: vi.fn() }; - }, - ); - vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation( - (listener) => { - onDidRenameFilesListener = listener; - return { dispose: vi.fn() }; - }, - ); - - context = { - subscriptions: [], - } as unknown as vscode.ExtensionContext; - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - const getUri = (path: string) => - vscode.Uri.file(path) as unknown as vscode.Uri; - - it('adds a file to the list', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); - - it('moves an existing file to the top', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - manager.add(uri1); - manager.add(uri2); - manager.add(uri1); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(2); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); - - it('does not exceed the max number of files', async () => { - const manager = new RecentFilesManager(context); - for (let i = 0; i < MAX_FILES + 5; i++) { - const uri = getUri(`/test/file${i}.txt`); - manager.add(uri); - } - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(MAX_FILES); - expect(manager.recentFiles[0].filePath).toBe( - `/test/file${MAX_FILES + 4}.txt`, - ); - expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`); - }); - - it('fires onDidChange when a file is added', async () => { - const manager = new RecentFilesManager(context); - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - const uri = getUri('/test/file1.txt'); - manager.add(uri); - - await vi.advanceTimersByTimeAsync(100); - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes a file when it is closed', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - - onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(0); - }); - - it('fires onDidChange when a file is removed', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); - await vi.advanceTimersByTimeAsync(100); - - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes a file when it is deleted', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - manager.add(uri1); - manager.add(uri2); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(2); - - onDidDeleteFilesListener({ files: [uri1] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('fires onDidChange when a file is deleted', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - onDidDeleteFilesListener({ files: [uri] }); - await vi.advanceTimersByTimeAsync(100); - - expect(onDidChangeSpy).toHaveBeenCalled(); - }); - - it('removes multiple files when they are deleted', async () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - const uri2 = getUri('/test/file2.txt'); - const uri3 = getUri('/test/file3.txt'); - manager.add(uri1); - manager.add(uri2); - manager.add(uri3); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(3); - - onDidDeleteFilesListener({ files: [uri1, uri3] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('prunes files older than the max age', () => { - const manager = new RecentFilesManager(context); - const uri1 = getUri('/test/file1.txt'); - manager.add(uri1); - - // Advance time by more than the max age - const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000; - vi.advanceTimersByTime(twoMinutesMs); - - const uri2 = getUri('/test/file2.txt'); - manager.add(uri2); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('fires onDidChange only once when adding an existing file', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - - const onDidChangeSpy = vi.fn(); - manager.onDidChange(onDidChangeSpy); - - manager.add(uri); - await vi.advanceTimersByTimeAsync(100); - expect(onDidChangeSpy).toHaveBeenCalledTimes(1); - }); - - it('updates the file when it is renamed', async () => { - const manager = new RecentFilesManager(context); - const oldUri = getUri('/test/file1.txt'); - const newUri = getUri('/test/file2.txt'); - manager.add(oldUri); - await vi.advanceTimersByTimeAsync(100); - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - - onDidRenameFilesListener({ files: [{ oldUri, newUri }] }); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); - }); - - it('adds a file when the active editor changes', async () => { - const manager = new RecentFilesManager(context); - const uri = getUri('/test/file1.txt'); - - onDidChangeActiveTextEditorListener({ - document: { uri }, - } as vscode.TextEditor); - await vi.advanceTimersByTimeAsync(100); - - expect(manager.recentFiles).toHaveLength(1); - expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); - }); -}); diff --git a/packages/vscode-ide-companion/src/recent-files-manager.ts b/packages/vscode-ide-companion/src/recent-files-manager.ts deleted file mode 100644 index 317cc903..00000000 --- a/packages/vscode-ide-companion/src/recent-files-manager.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode'; - -export const MAX_FILES = 10; -export const MAX_FILE_AGE_MINUTES = 5; - -interface RecentFile { - uri: vscode.Uri; - timestamp: number; -} - -/** - * Keeps track of the 10 most recently-opened files - * opened less than 5 min ago. If a file is closed or deleted, - * it will be removed. If the max length is reached, older files will get removed first. - */ -export class RecentFilesManager { - private readonly files: RecentFile[] = []; - private readonly onDidChangeEmitter = new vscode.EventEmitter(); - readonly onDidChange = this.onDidChangeEmitter.event; - private debounceTimer: NodeJS.Timeout | undefined; - - constructor(private readonly context: vscode.ExtensionContext) { - const editorWatcher = vscode.window.onDidChangeActiveTextEditor( - (editor) => { - if (editor) { - this.add(editor.document.uri); - } - }, - ); - const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { - for (const uri of event.files) { - this.remove(uri); - } - }); - const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { - this.remove(document.uri); - }); - const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { - for (const { oldUri, newUri } of event.files) { - this.remove(oldUri, false); - this.add(newUri); - } - }); - - const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( - () => { - this.fireWithDebounce(); - }, - ); - - context.subscriptions.push( - editorWatcher, - deleteWatcher, - closeWatcher, - renameWatcher, - selectionWatcher, - ); - } - - private fireWithDebounce() { - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - this.debounceTimer = setTimeout(() => { - this.onDidChangeEmitter.fire(); - }, 50); // 50ms - } - - private remove(uri: vscode.Uri, fireEvent = true) { - const index = this.files.findIndex( - (file) => file.uri.fsPath === uri.fsPath, - ); - if (index !== -1) { - this.files.splice(index, 1); - if (fireEvent) { - this.fireWithDebounce(); - } - } - } - - add(uri: vscode.Uri) { - if (uri.scheme !== 'file') { - return; - } - - this.remove(uri, false); - this.files.unshift({ uri, timestamp: Date.now() }); - - if (this.files.length > MAX_FILES) { - this.files.pop(); - } - this.fireWithDebounce(); - } - - get recentFiles(): Array<{ filePath: string; timestamp: number }> { - const now = Date.now(); - const maxAgeInMs = MAX_FILE_AGE_MINUTES * 60 * 1000; - return this.files - .filter((file) => now - file.timestamp < maxAgeInMs) - .map((file) => ({ - filePath: file.uri.fsPath, - timestamp: file.timestamp, - })); - } -}