summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json3
-rw-r--r--packages/core/src/services/ideContext.ts8
-rw-r--r--packages/vscode-ide-companion/package.json7
-rw-r--r--packages/vscode-ide-companion/src/extension.ts1
-rw-r--r--packages/vscode-ide-companion/src/ide-server.ts22
-rw-r--r--packages/vscode-ide-companion/src/recent-files-manager.test.ts211
-rw-r--r--packages/vscode-ide-companion/src/recent-files-manager.ts92
-rw-r--r--packages/vscode-ide-companion/tsconfig.json3
8 files changed, 337 insertions, 10 deletions
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<typeof CursorSchema>;
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<typeof ActiveFileSchema>;
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<void>();
+ 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,