Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059)
This commit is contained in:
parent
9aef0a8e6c
commit
cfe3753d4c
|
@ -14,59 +14,22 @@ import {
|
||||||
type JSONRPCNotification,
|
type JSONRPCNotification,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { Server as HTTPServer } from 'node:http';
|
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 MCP_SESSION_ID_HEADER = 'mcp-session-id';
|
||||||
const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT';
|
const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT';
|
||||||
const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
|
|
||||||
|
|
||||||
function sendIdeContextUpdateNotification(
|
function sendIdeContextUpdateNotification(
|
||||||
transport: StreamableHTTPServerTransport,
|
transport: StreamableHTTPServerTransport,
|
||||||
log: (message: string) => void,
|
log: (message: string) => void,
|
||||||
recentFilesManager: RecentFilesManager,
|
openFilesManager: OpenFilesManager,
|
||||||
) {
|
) {
|
||||||
const editor = vscode.window.activeTextEditor;
|
const ideContext = openFilesManager.state;
|
||||||
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 notification: JSONRPCNotification = {
|
const notification: JSONRPCNotification = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
method: 'ide/contextUpdate',
|
method: 'ide/contextUpdate',
|
||||||
params: {
|
params: ideContext,
|
||||||
workspaceState: {
|
|
||||||
openFiles,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
log(
|
log(
|
||||||
`Sending IDE context update notification: ${JSON.stringify(
|
`Sending IDE context update notification: ${JSON.stringify(
|
||||||
|
@ -97,13 +60,13 @@ export class IDEServer {
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
const mcpServer = createMcpServer();
|
const mcpServer = createMcpServer();
|
||||||
|
|
||||||
const recentFilesManager = new RecentFilesManager(context);
|
const openFilesManager = new OpenFilesManager(context);
|
||||||
const onDidChangeSubscription = recentFilesManager.onDidChange(() => {
|
const onDidChangeSubscription = openFilesManager.onDidChange(() => {
|
||||||
for (const transport of Object.values(transports)) {
|
for (const transport of Object.values(transports)) {
|
||||||
sendIdeContextUpdateNotification(
|
sendIdeContextUpdateNotification(
|
||||||
transport,
|
transport,
|
||||||
this.log.bind(this),
|
this.log.bind(this),
|
||||||
recentFilesManager,
|
openFilesManager,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -207,7 +170,7 @@ export class IDEServer {
|
||||||
sendIdeContextUpdateNotification(
|
sendIdeContextUpdateNotification(
|
||||||
transport,
|
transport,
|
||||||
this.log.bind(this),
|
this.log.bind(this),
|
||||||
recentFilesManager,
|
openFilesManager,
|
||||||
);
|
);
|
||||||
sessionsWithInitialNotification.add(sessionId);
|
sessionsWithInitialNotification.add(sessionId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue