diff --git a/package-lock.json b/package-lock.json index d14a18e8..475d01e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11891,7 +11891,8 @@ "esbuild": "^0.25.3", "eslint": "^9.25.1", "npm-run-all": "^4.1.5", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.4" }, "engines": { "vscode": "^1.101.0" diff --git a/packages/core/src/services/ideContext.ts b/packages/core/src/services/ideContext.ts index b5f0b4af..0aab1e8d 100644 --- a/packages/core/src/services/ideContext.ts +++ b/packages/core/src/services/ideContext.ts @@ -26,6 +26,14 @@ export type Cursor = z.infer; export const ActiveFileSchema = z.object({ filePath: z.string(), cursor: CursorSchema.optional(), + recentOpenFiles: z + .array( + z.object({ + filePath: z.string(), + timestamp: z.number(), + }), + ) + .optional(), }); export type ActiveFile = z.infer; diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 00c5731b..7e23842e 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -30,8 +30,8 @@ "package": "vsce package --no-dependencies", "check-types": "tsc --noEmit", "lint": "eslint src", - "test": "echo \"vscode-ide-companion has no tests yet\"", - "test:ci": "echo \"vscode-ide-companion has no tests yet\"" + "test": "vitest run", + "test:ci": "vitest run --coverage" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -43,7 +43,8 @@ "esbuild": "^0.25.3", "eslint": "^9.25.1", "npm-run-all": "^4.1.5", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^3.2.4" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 62901793..71d3138f 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -12,7 +12,6 @@ let logger: vscode.OutputChannel; export async function activate(context: vscode.ExtensionContext) { logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion'); - logger.show(); logger.appendLine('Starting Gemini CLI IDE Companion server...'); ideServer = new IDEServer(logger); try { diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index d14b0d7a..37e07737 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -14,6 +14,7 @@ import { type JSONRPCNotification, } from '@modelcontextprotocol/sdk/types.js'; import { Server as HTTPServer } from 'node:http'; +import { RecentFilesManager } from './recent-files-manager.js'; const MCP_SESSION_ID_HEADER = 'mcp-session-id'; const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; @@ -21,6 +22,7 @@ const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; function sendActiveFileChangedNotification( transport: StreamableHTTPServerTransport, logger: vscode.OutputChannel, + recentFilesManager: RecentFilesManager, ) { const editor = vscode.window.activeTextEditor; const filePath = editor ? editor.document.uri.fsPath : ''; @@ -28,7 +30,10 @@ function sendActiveFileChangedNotification( const notification: JSONRPCNotification = { jsonrpc: '2.0', method: 'ide/activeFileChanged', - params: { filePath }, + params: { + filePath, + recentOpenFiles: recentFilesManager.recentFiles, + }, }; transport.send(notification); } @@ -52,9 +57,14 @@ export class IDEServer { app.use(express.json()); const mcpServer = createMcpServer(); - const disposable = vscode.window.onDidChangeActiveTextEditor((_editor) => { + const recentFilesManager = new RecentFilesManager(context); + const disposable = recentFilesManager.onDidChange(() => { for (const transport of Object.values(transports)) { - sendActiveFileChangedNotification(transport, this.logger); + sendActiveFileChangedNotification( + transport, + this.logger, + recentFilesManager, + ); } }); context.subscriptions.push(disposable); @@ -158,7 +168,11 @@ export class IDEServer { } if (!sessionsWithInitialNotification.has(sessionId)) { - sendActiveFileChangedNotification(transport, this.logger); + sendActiveFileChangedNotification( + transport, + this.logger, + recentFilesManager, + ); sessionsWithInitialNotification.add(sessionId); } }; diff --git a/packages/vscode-ide-companion/src/recent-files-manager.test.ts b/packages/vscode-ide-companion/src/recent-files-manager.test.ts new file mode 100644 index 00000000..27742ed2 --- /dev/null +++ b/packages/vscode-ide-companion/src/recent-files-manager.test.ts @@ -0,0 +1,211 @@ +/** + * @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(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), + window: { + onDidChangeActiveTextEditor: vi.fn(), + }, + workspace: { + onDidDeleteFiles: vi.fn(), + onDidCloseTextDocument: vi.fn(), + onDidRenameFiles: vi.fn(), + }, + Uri: { + file: (path: string) => ({ + fsPath: path, + }), + }, +})); + +describe('RecentFilesManager', () => { + let context: vscode.ExtensionContext; + + beforeEach(() => { + context = { + subscriptions: [], + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds a file to the list', () => { + const manager = new RecentFilesManager(context); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); + }); + + it('moves an existing file to the top', () => { + const manager = new RecentFilesManager(context); + const uri1 = vscode.Uri.file('/test/file1.txt'); + const uri2 = vscode.Uri.file('/test/file2.txt'); + manager.add(uri1); + manager.add(uri2); + manager.add(uri1); + expect(manager.recentFiles).toHaveLength(2); + expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); + }); + + it('does not exceed the max number of files', () => { + const manager = new RecentFilesManager(context); + for (let i = 0; i < MAX_FILES + 5; i++) { + const uri = vscode.Uri.file(`/test/file${i}.txt`); + manager.add(uri); + } + 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', () => { + const manager = new RecentFilesManager(context); + const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + expect(spy).toHaveBeenCalled(); + }); + + it('removes a file when it is closed', () => { + const manager = new RecentFilesManager(context); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + expect(manager.recentFiles).toHaveLength(1); + + // Simulate closing the file + const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock + .calls[0][0]; + closeHandler({ uri } as vscode.TextDocument); + + expect(manager.recentFiles).toHaveLength(0); + }); + + it('fires onDidChange when a file is removed', () => { + const manager = new RecentFilesManager(context); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + + const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); + const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock + .calls[0][0]; + closeHandler({ uri } as vscode.TextDocument); + + expect(spy).toHaveBeenCalled(); + }); + + it('removes a file when it is deleted', () => { + const manager = new RecentFilesManager(context); + const uri1 = vscode.Uri.file('/test/file1.txt'); + const uri2 = vscode.Uri.file('/test/file2.txt'); + manager.add(uri1); + manager.add(uri2); + expect(manager.recentFiles).toHaveLength(2); + + // Simulate deleting a file + const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock + .calls[0][0]; + deleteHandler({ files: [uri1] }); + + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); + }); + + it('fires onDidChange when a file is deleted', () => { + const manager = new RecentFilesManager(context); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + + const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); + const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock + .calls[0][0]; + deleteHandler({ files: [uri] }); + + expect(spy).toHaveBeenCalled(); + }); + + it('removes multiple files when they are deleted', () => { + const manager = new RecentFilesManager(context); + const uri1 = vscode.Uri.file('/test/file1.txt'); + const uri2 = vscode.Uri.file('/test/file2.txt'); + const uri3 = vscode.Uri.file('/test/file3.txt'); + manager.add(uri1); + manager.add(uri2); + manager.add(uri3); + expect(manager.recentFiles).toHaveLength(3); + + // Simulate deleting multiple files + const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock + .calls[0][0]; + deleteHandler({ files: [uri1, uri3] }); + + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); + }); + + it('prunes files older than the max age', () => { + vi.useFakeTimers(); + + const manager = new RecentFilesManager(context); + const uri1 = vscode.Uri.file('/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 = vscode.Uri.file('/test/file2.txt'); + manager.add(uri2); + + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); + + vi.useRealTimers(); + }); + + it('fires onDidChange only once when adding an existing file', () => { + const manager = new RecentFilesManager(context); + const uri = vscode.Uri.file('/test/file1.txt'); + manager.add(uri); + + const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); + manager.add(uri); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('updates the file when it is renamed', () => { + const manager = new RecentFilesManager(context); + const oldUri = vscode.Uri.file('/test/file1.txt'); + const newUri = vscode.Uri.file('/test/file2.txt'); + manager.add(oldUri); + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); + + // Simulate renaming the file + const renameHandler = vi.mocked(vscode.workspace.onDidRenameFiles).mock + .calls[0][0]; + renameHandler({ files: [{ oldUri, newUri }] }); + + expect(manager.recentFiles).toHaveLength(1); + expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); + }); +}); diff --git a/packages/vscode-ide-companion/src/recent-files-manager.ts b/packages/vscode-ide-companion/src/recent-files-manager.ts new file mode 100644 index 00000000..84316363 --- /dev/null +++ b/packages/vscode-ide-companion/src/recent-files-manager.ts @@ -0,0 +1,92 @@ +/** + * @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 ago. If a file is closed or deleted, + * it will be removed. If the length is maxxed out, + * the now-removed file will not be replaced by an older file. + */ +export class RecentFilesManager { + private readonly files: RecentFile[] = []; + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + constructor(private readonly context: vscode.ExtensionContext) { + const editorWatcher = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + if (editor) { + this.add(editor.document.uri); + } + }, + ); + const fileWatcher = 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); + } + }); + context.subscriptions.push( + editorWatcher, + fileWatcher, + closeWatcher, + renameWatcher, + ); + } + + 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.onDidChangeEmitter.fire(); + } + } + } + + add(uri: vscode.Uri) { + // Remove if it already exists to avoid duplicates and move it to the top. + this.remove(uri, false); + + this.files.unshift({ uri, timestamp: Date.now() }); + + if (this.files.length > MAX_FILES) { + this.files.pop(); + } + this.onDidChangeEmitter.fire(); + } + + 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, + })); + } +} diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 90bc8247..2fec2bd9 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "module": "Node16", + "module": "NodeNext", + "moduleResolution": "NodeNext", "target": "ES2022", "lib": ["ES2022", "dom"], "sourceMap": true,