summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/vscode-ide-companion/src/ide-server.ts53
-rw-r--r--packages/vscode-ide-companion/src/open-files-manager.test.ts440
-rw-r--r--packages/vscode-ide-companion/src/open-files-manager.ts178
-rw-r--r--packages/vscode-ide-companion/src/recent-files-manager.test.ts278
-rw-r--r--packages/vscode-ide-companion/src/recent-files-manager.ts111
5 files changed, 626 insertions, 434 deletions
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<void>();
+ 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<void>();
- 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,
- }));
- }
-}