Minor refactoring of VS Code companion extension code (#4761)

This commit is contained in:
Shreya Keshive 2025-07-24 01:32:55 -04:00 committed by GitHub
parent b1e0fb157b
commit 6380bfe35c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 244 additions and 115 deletions

12
.vscode/launch.json vendored
View File

@ -30,6 +30,18 @@
"GEMINI_SANDBOX": "false" "GEMINI_SANDBOX": "false"
} }
}, },
{
"name": "Launch Companion VS Code Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-companion"
],
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "npm: build: vscode-ide-companion"
},
{ {
"name": "Attach", "name": "Attach",
"port": 9229, "port": 9229,

9
.vscode/tasks.json vendored
View File

@ -11,6 +11,15 @@
"problemMatcher": [], "problemMatcher": [],
"label": "npm: build", "label": "npm: build",
"detail": "scripts/build.sh" "detail": "scripts/build.sh"
},
{
"type": "npm",
"script": "build",
"path": "packages/vscode-ide-companion",
"group": "build",
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
} }
] ]
} }

View File

@ -13,7 +13,7 @@ The Gemini CLI Companion extension seamlessly integrates [Gemini CLI](https://gi
To use this extension, you'll need: To use this extension, you'll need:
- VS Code version 1.101.0 or newer - VS Code version 1.101.0 or newer
- Gemini CLI (installed separately) and running within the VS Code integrated terminal - Gemini CLI (installed separately) running within the VS Code integrated terminal
# Terms of Service and Privacy Notice # Terms of Service and Privacy Notice

View File

@ -6,30 +6,38 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { IDEServer } from './ide-server'; import { IDEServer } from './ide-server';
import { createLogger } from './utils/logger';
let ideServer: IDEServer; let ideServer: IDEServer;
let logger: vscode.OutputChannel; let logger: vscode.OutputChannel;
let log: (message: string) => void = () => {};
export async function activate(context: vscode.ExtensionContext) { export async function activate(context: vscode.ExtensionContext) {
logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion'); logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion');
logger.appendLine('Starting Gemini CLI IDE Companion server...'); log = createLogger(context, logger);
ideServer = new IDEServer(logger);
log('Extension activated');
ideServer = new IDEServer(log);
try { try {
await ideServer.start(context); await ideServer.start(context);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
logger.appendLine(`Failed to start IDE server: ${message}`); log(`Failed to start IDE server: ${message}`);
} }
} }
export function deactivate() { export async function deactivate(): Promise<void> {
if (ideServer) { log('Extension deactivated');
logger.appendLine('Deactivating Gemini CLI IDE Companion...'); try {
return ideServer.stop().finally(() => { if (ideServer) {
await ideServer.stop();
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log(`Failed to stop IDE server during deactivation: ${message}`);
} finally {
if (logger) {
logger.dispose(); logger.dispose();
}); }
}
if (logger) {
logger.dispose();
} }
} }

View File

@ -21,30 +21,41 @@ const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT';
function sendOpenFilesChangedNotification( function sendOpenFilesChangedNotification(
transport: StreamableHTTPServerTransport, transport: StreamableHTTPServerTransport,
logger: vscode.OutputChannel, log: (message: string) => void,
recentFilesManager: RecentFilesManager, recentFilesManager: RecentFilesManager,
) { ) {
const editor = vscode.window.activeTextEditor; const editor = vscode.window.activeTextEditor;
const filePath = editor ? editor.document.uri.fsPath : ''; const filePath =
logger.appendLine(`Sending active file changed notification: ${filePath}`); editor && editor.document.uri.scheme === 'file'
? editor.document.uri.fsPath
: '';
const notification: JSONRPCNotification = { const notification: JSONRPCNotification = {
jsonrpc: '2.0', jsonrpc: '2.0',
method: 'ide/openFilesChanged', method: 'ide/openFilesChanged',
params: { params: {
activeFile: filePath, activeFile: filePath,
recentOpenFiles: recentFilesManager.recentFiles, recentOpenFiles: recentFilesManager.recentFiles.filter(
(file) => file.filePath !== filePath,
),
}, },
}; };
log(
`Sending active file changed notification: ${JSON.stringify(
notification,
null,
2,
)}`,
);
transport.send(notification); transport.send(notification);
} }
export class IDEServer { export class IDEServer {
private server: HTTPServer | undefined; private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined; private context: vscode.ExtensionContext | undefined;
private logger: vscode.OutputChannel; private log: (message: string) => void;
constructor(logger: vscode.OutputChannel) { constructor(log: (message: string) => void) {
this.logger = logger; this.log = log;
} }
async start(context: vscode.ExtensionContext) { async start(context: vscode.ExtensionContext) {
@ -62,7 +73,7 @@ export class IDEServer {
for (const transport of Object.values(transports)) { for (const transport of Object.values(transports)) {
sendOpenFilesChangedNotification( sendOpenFilesChangedNotification(
transport, transport,
this.logger, this.log.bind(this),
recentFilesManager, recentFilesManager,
); );
} }
@ -81,7 +92,7 @@ export class IDEServer {
transport = new StreamableHTTPServerTransport({ transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => { onsessioninitialized: (newSessionId) => {
this.logger.appendLine(`New session initialized: ${newSessionId}`); this.log(`New session initialized: ${newSessionId}`);
transports[newSessionId] = transport; transports[newSessionId] = transport;
}, },
}); });
@ -90,26 +101,24 @@ export class IDEServer {
try { try {
transport.send({ jsonrpc: '2.0', method: 'ping' }); transport.send({ jsonrpc: '2.0', method: 'ping' });
} catch (e) { } catch (e) {
// If sending a ping fails, the connection is likely broken. this.log(
// Log the error and clear the interval to prevent further attempts.
this.logger.append(
'Failed to send keep-alive ping, cleaning up interval.' + e, 'Failed to send keep-alive ping, cleaning up interval.' + e,
); );
clearInterval(keepAlive); clearInterval(keepAlive);
} }
}, 60000); // Send ping every 60 seconds }, 60000); // 60 sec
transport.onclose = () => { transport.onclose = () => {
clearInterval(keepAlive); clearInterval(keepAlive);
if (transport.sessionId) { if (transport.sessionId) {
this.logger.appendLine(`Session closed: ${transport.sessionId}`); this.log(`Session closed: ${transport.sessionId}`);
sessionsWithInitialNotification.delete(transport.sessionId); sessionsWithInitialNotification.delete(transport.sessionId);
delete transports[transport.sessionId]; delete transports[transport.sessionId];
} }
}; };
mcpServer.connect(transport); mcpServer.connect(transport);
} else { } else {
this.logger.appendLine( this.log(
'Bad Request: No valid session ID provided for non-initialize request.', 'Bad Request: No valid session ID provided for non-initialize request.',
); );
res.status(400).json({ res.status(400).json({
@ -129,7 +138,7 @@ export class IDEServer {
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : 'Unknown error'; error instanceof Error ? error.message : 'Unknown error';
this.logger.appendLine(`Error handling MCP request: ${errorMessage}`); this.log(`Error handling MCP request: ${errorMessage}`);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
jsonrpc: '2.0' as const, jsonrpc: '2.0' as const,
@ -148,7 +157,7 @@ export class IDEServer {
| string | string
| undefined; | undefined;
if (!sessionId || !transports[sessionId]) { if (!sessionId || !transports[sessionId]) {
this.logger.appendLine('Invalid or missing session ID'); this.log('Invalid or missing session ID');
res.status(400).send('Invalid or missing session ID'); res.status(400).send('Invalid or missing session ID');
return; return;
} }
@ -159,9 +168,7 @@ export class IDEServer {
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : 'Unknown error'; error instanceof Error ? error.message : 'Unknown error';
this.logger.appendLine( this.log(`Error handling session request: ${errorMessage}`);
`Error handling session request: ${errorMessage}`,
);
if (!res.headersSent) { if (!res.headersSent) {
res.status(400).send('Bad Request'); res.status(400).send('Bad Request');
} }
@ -170,7 +177,7 @@ export class IDEServer {
if (!sessionsWithInitialNotification.has(sessionId)) { if (!sessionsWithInitialNotification.has(sessionId)) {
sendOpenFilesChangedNotification( sendOpenFilesChangedNotification(
transport, transport,
this.logger, this.log.bind(this),
recentFilesManager, recentFilesManager,
); );
sessionsWithInitialNotification.add(sessionId); sessionsWithInitialNotification.add(sessionId);
@ -187,7 +194,7 @@ export class IDEServer {
IDE_SERVER_PORT_ENV_VAR, IDE_SERVER_PORT_ENV_VAR,
port.toString(), port.toString(),
); );
this.logger.appendLine(`IDE server listening on port ${port}`); this.log(`IDE server listening on port ${port}`);
} }
}); });
} }
@ -197,12 +204,10 @@ export class IDEServer {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
this.server!.close((err?: Error) => { this.server!.close((err?: Error) => {
if (err) { if (err) {
this.logger.appendLine( this.log(`Error shutting down IDE server: ${err.message}`);
`Error shutting down IDE server: ${err.message}`,
);
return reject(err); return reject(err);
} }
this.logger.appendLine(`IDE server shut down`); this.log(`IDE server shut down`);
resolve(); resolve();
}); });
}); });

View File

@ -13,11 +13,19 @@ import {
} from './recent-files-manager.js'; } from './recent-files-manager.js';
vi.mock('vscode', () => ({ vi.mock('vscode', () => ({
EventEmitter: vi.fn(() => ({ EventEmitter: vi.fn(() => {
event: vi.fn(), const listeners: Array<(e: void) => unknown> = [];
fire: vi.fn(), return {
dispose: vi.fn(), event: vi.fn((listener) => {
})), listeners.push(listener);
return { dispose: vi.fn() };
}),
fire: vi.fn(() => {
listeners.forEach((listener) => listener(undefined));
}),
dispose: vi.fn(),
};
}),
window: { window: {
onDidChangeActiveTextEditor: vi.fn(), onDidChangeActiveTextEditor: vi.fn(),
}, },
@ -29,14 +37,48 @@ vi.mock('vscode', () => ({
Uri: { Uri: {
file: (path: string) => ({ file: (path: string) => ({
fsPath: path, fsPath: path,
scheme: 'file',
}), }),
}, },
})); }));
describe('RecentFilesManager', () => { describe('RecentFilesManager', () => {
let context: vscode.ExtensionContext; 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(() => { 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 = { context = {
subscriptions: [], subscriptions: [],
} as unknown as vscode.ExtensionContext; } as unknown as vscode.ExtensionContext;
@ -44,33 +86,40 @@ describe('RecentFilesManager', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers();
}); });
it('adds a file to the list', () => { 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 manager = new RecentFilesManager(context);
const uri = vscode.Uri.file('/test/file1.txt'); const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
}); });
it('moves an existing file to the top', () => { it('moves an existing file to the top', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri1 = vscode.Uri.file('/test/file1.txt'); const uri1 = getUri('/test/file1.txt');
const uri2 = vscode.Uri.file('/test/file2.txt'); const uri2 = getUri('/test/file2.txt');
manager.add(uri1); manager.add(uri1);
manager.add(uri2); manager.add(uri2);
manager.add(uri1); manager.add(uri1);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(2); expect(manager.recentFiles).toHaveLength(2);
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
}); });
it('does not exceed the max number of files', () => { it('does not exceed the max number of files', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
for (let i = 0; i < MAX_FILES + 5; i++) { for (let i = 0; i < MAX_FILES + 5; i++) {
const uri = vscode.Uri.file(`/test/file${i}.txt`); const uri = getUri(`/test/file${i}.txt`);
manager.add(uri); manager.add(uri);
} }
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(MAX_FILES); expect(manager.recentFiles).toHaveLength(MAX_FILES);
expect(manager.recentFiles[0].filePath).toBe( expect(manager.recentFiles[0].filePath).toBe(
`/test/file${MAX_FILES + 4}.txt`, `/test/file${MAX_FILES + 4}.txt`,
@ -78,134 +127,151 @@ describe('RecentFilesManager', () => {
expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`); expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`);
}); });
it('fires onDidChange when a file is added', () => { it('fires onDidChange when a file is added', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); const onDidChangeSpy = vi.fn();
const uri = vscode.Uri.file('/test/file1.txt'); manager.onDidChange(onDidChangeSpy);
const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
expect(spy).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
expect(onDidChangeSpy).toHaveBeenCalled();
}); });
it('removes a file when it is closed', () => { it('removes a file when it is closed', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri = vscode.Uri.file('/test/file1.txt'); const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
// Simulate closing the file onDidCloseTextDocumentListener({ uri } as vscode.TextDocument);
const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock await vi.advanceTimersByTimeAsync(100);
.calls[0][0];
closeHandler({ uri } as vscode.TextDocument);
expect(manager.recentFiles).toHaveLength(0); expect(manager.recentFiles).toHaveLength(0);
}); });
it('fires onDidChange when a file is removed', () => { it('fires onDidChange when a file is removed', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri = vscode.Uri.file('/test/file1.txt'); const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
await vi.advanceTimersByTimeAsync(100);
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); const onDidChangeSpy = vi.fn();
const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock manager.onDidChange(onDidChangeSpy);
.calls[0][0];
closeHandler({ uri } as vscode.TextDocument);
expect(spy).toHaveBeenCalled(); onDidCloseTextDocumentListener({ uri } as vscode.TextDocument);
await vi.advanceTimersByTimeAsync(100);
expect(onDidChangeSpy).toHaveBeenCalled();
}); });
it('removes a file when it is deleted', () => { it('removes a file when it is deleted', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri1 = vscode.Uri.file('/test/file1.txt'); const uri1 = getUri('/test/file1.txt');
const uri2 = vscode.Uri.file('/test/file2.txt'); const uri2 = getUri('/test/file2.txt');
manager.add(uri1); manager.add(uri1);
manager.add(uri2); manager.add(uri2);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(2); expect(manager.recentFiles).toHaveLength(2);
// Simulate deleting a file onDidDeleteFilesListener({ files: [uri1] });
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock await vi.advanceTimersByTimeAsync(100);
.calls[0][0];
deleteHandler({ files: [uri1] });
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
}); });
it('fires onDidChange when a file is deleted', () => { it('fires onDidChange when a file is deleted', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri = vscode.Uri.file('/test/file1.txt'); const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
await vi.advanceTimersByTimeAsync(100);
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire'); const onDidChangeSpy = vi.fn();
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock manager.onDidChange(onDidChangeSpy);
.calls[0][0];
deleteHandler({ files: [uri] });
expect(spy).toHaveBeenCalled(); onDidDeleteFilesListener({ files: [uri] });
await vi.advanceTimersByTimeAsync(100);
expect(onDidChangeSpy).toHaveBeenCalled();
}); });
it('removes multiple files when they are deleted', () => { it('removes multiple files when they are deleted', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri1 = vscode.Uri.file('/test/file1.txt'); const uri1 = getUri('/test/file1.txt');
const uri2 = vscode.Uri.file('/test/file2.txt'); const uri2 = getUri('/test/file2.txt');
const uri3 = vscode.Uri.file('/test/file3.txt'); const uri3 = getUri('/test/file3.txt');
manager.add(uri1); manager.add(uri1);
manager.add(uri2); manager.add(uri2);
manager.add(uri3); manager.add(uri3);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(3); expect(manager.recentFiles).toHaveLength(3);
// Simulate deleting multiple files onDidDeleteFilesListener({ files: [uri1, uri3] });
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock await vi.advanceTimersByTimeAsync(100);
.calls[0][0];
deleteHandler({ files: [uri1, uri3] });
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
}); });
it('prunes files older than the max age', () => { it('prunes files older than the max age', () => {
vi.useFakeTimers();
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri1 = vscode.Uri.file('/test/file1.txt'); const uri1 = getUri('/test/file1.txt');
manager.add(uri1); manager.add(uri1);
// Advance time by more than the max age // Advance time by more than the max age
const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000; const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000;
vi.advanceTimersByTime(twoMinutesMs); vi.advanceTimersByTime(twoMinutesMs);
const uri2 = vscode.Uri.file('/test/file2.txt'); const uri2 = getUri('/test/file2.txt');
manager.add(uri2); manager.add(uri2);
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
vi.useRealTimers();
}); });
it('fires onDidChange only once when adding an existing file', () => { it('fires onDidChange only once when adding an existing file', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const uri = vscode.Uri.file('/test/file1.txt'); const uri = getUri('/test/file1.txt');
manager.add(uri); manager.add(uri);
await vi.advanceTimersByTimeAsync(100);
const onDidChangeSpy = vi.fn();
manager.onDidChange(onDidChangeSpy);
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire');
manager.add(uri); manager.add(uri);
expect(spy).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(100);
expect(onDidChangeSpy).toHaveBeenCalledTimes(1);
}); });
it('updates the file when it is renamed', () => { it('updates the file when it is renamed', async () => {
const manager = new RecentFilesManager(context); const manager = new RecentFilesManager(context);
const oldUri = vscode.Uri.file('/test/file1.txt'); const oldUri = getUri('/test/file1.txt');
const newUri = vscode.Uri.file('/test/file2.txt'); const newUri = getUri('/test/file2.txt');
manager.add(oldUri); manager.add(oldUri);
await vi.advanceTimersByTimeAsync(100);
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt'); expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
// Simulate renaming the file onDidRenameFilesListener({ files: [{ oldUri, newUri }] });
const renameHandler = vi.mocked(vscode.workspace.onDidRenameFiles).mock await vi.advanceTimersByTimeAsync(100);
.calls[0][0];
renameHandler({ files: [{ oldUri, newUri }] });
expect(manager.recentFiles).toHaveLength(1); expect(manager.recentFiles).toHaveLength(1);
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt'); 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');
});
}); });

View File

@ -16,14 +16,14 @@ interface RecentFile {
/** /**
* Keeps track of the 10 most recently-opened files * Keeps track of the 10 most recently-opened files
* opened less than 5 ago. If a file is closed or deleted, * opened less than 5 min ago. If a file is closed or deleted,
* it will be removed. If the length is maxxed out, * it will be removed. If the max length is reached, older files will get removed first.
* the now-removed file will not be replaced by an older file.
*/ */
export class RecentFilesManager { export class RecentFilesManager {
private readonly files: RecentFile[] = []; private readonly files: RecentFile[] = [];
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>(); private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
readonly onDidChange = this.onDidChangeEmitter.event; readonly onDidChange = this.onDidChangeEmitter.event;
private debounceTimer: NodeJS.Timeout | undefined;
constructor(private readonly context: vscode.ExtensionContext) { constructor(private readonly context: vscode.ExtensionContext) {
const editorWatcher = vscode.window.onDidChangeActiveTextEditor( const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
@ -33,7 +33,7 @@ export class RecentFilesManager {
} }
}, },
); );
const fileWatcher = vscode.workspace.onDidDeleteFiles((event) => { const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
for (const uri of event.files) { for (const uri of event.files) {
this.remove(uri); this.remove(uri);
} }
@ -49,12 +49,21 @@ export class RecentFilesManager {
}); });
context.subscriptions.push( context.subscriptions.push(
editorWatcher, editorWatcher,
fileWatcher, deleteWatcher,
closeWatcher, closeWatcher,
renameWatcher, renameWatcher,
); );
} }
private fireWithDebounce() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.onDidChangeEmitter.fire();
}, 50); // 50ms
}
private remove(uri: vscode.Uri, fireEvent = true) { private remove(uri: vscode.Uri, fireEvent = true) {
const index = this.files.findIndex( const index = this.files.findIndex(
(file) => file.uri.fsPath === uri.fsPath, (file) => file.uri.fsPath === uri.fsPath,
@ -62,21 +71,23 @@ export class RecentFilesManager {
if (index !== -1) { if (index !== -1) {
this.files.splice(index, 1); this.files.splice(index, 1);
if (fireEvent) { if (fireEvent) {
this.onDidChangeEmitter.fire(); this.fireWithDebounce();
} }
} }
} }
add(uri: vscode.Uri) { add(uri: vscode.Uri) {
// Remove if it already exists to avoid duplicates and move it to the top. if (uri.scheme !== 'file') {
this.remove(uri, false); return;
}
this.remove(uri, false);
this.files.unshift({ uri, timestamp: Date.now() }); this.files.unshift({ uri, timestamp: Date.now() });
if (this.files.length > MAX_FILES) { if (this.files.length > MAX_FILES) {
this.files.pop(); this.files.pop();
} }
this.onDidChangeEmitter.fire(); this.fireWithDebounce();
} }
get recentFiles(): Array<{ filePath: string; timestamp: number }> { get recentFiles(): Array<{ filePath: string; timestamp: number }> {

View File

@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
export function createLogger(
context: vscode.ExtensionContext,
logger: vscode.OutputChannel,
) {
return (message: string) => {
if (context.extensionMode === vscode.ExtensionMode.Development) {
logger.appendLine(message);
}
};
}