[ide-mode] Keep track of recently-opened files and send them to the CLI (#4463)
This commit is contained in:
parent
45b764943a
commit
9bdcdf97d8
|
@ -11891,7 +11891,8 @@
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.3",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.101.0"
|
"vscode": "^1.101.0"
|
||||||
|
|
|
@ -26,6 +26,14 @@ export type Cursor = z.infer<typeof CursorSchema>;
|
||||||
export const ActiveFileSchema = z.object({
|
export const ActiveFileSchema = z.object({
|
||||||
filePath: z.string(),
|
filePath: z.string(),
|
||||||
cursor: CursorSchema.optional(),
|
cursor: CursorSchema.optional(),
|
||||||
|
recentOpenFiles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
filePath: z.string(),
|
||||||
|
timestamp: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
export type ActiveFile = z.infer<typeof ActiveFileSchema>;
|
export type ActiveFile = z.infer<typeof ActiveFileSchema>;
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,8 @@
|
||||||
"package": "vsce package --no-dependencies",
|
"package": "vsce package --no-dependencies",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "echo \"vscode-ide-companion has no tests yet\"",
|
"test": "vitest run",
|
||||||
"test:ci": "echo \"vscode-ide-companion has no tests yet\""
|
"test:ci": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
@ -43,7 +43,8 @@
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.3",
|
||||||
"eslint": "^9.25.1",
|
"eslint": "^9.25.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
|
|
|
@ -12,7 +12,6 @@ let logger: vscode.OutputChannel;
|
||||||
|
|
||||||
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.show();
|
|
||||||
logger.appendLine('Starting Gemini CLI IDE Companion server...');
|
logger.appendLine('Starting Gemini CLI IDE Companion server...');
|
||||||
ideServer = new IDEServer(logger);
|
ideServer = new IDEServer(logger);
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -14,6 +14,7 @@ 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';
|
||||||
|
|
||||||
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';
|
||||||
|
@ -21,6 +22,7 @@ const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT';
|
||||||
function sendActiveFileChangedNotification(
|
function sendActiveFileChangedNotification(
|
||||||
transport: StreamableHTTPServerTransport,
|
transport: StreamableHTTPServerTransport,
|
||||||
logger: vscode.OutputChannel,
|
logger: vscode.OutputChannel,
|
||||||
|
recentFilesManager: RecentFilesManager,
|
||||||
) {
|
) {
|
||||||
const editor = vscode.window.activeTextEditor;
|
const editor = vscode.window.activeTextEditor;
|
||||||
const filePath = editor ? editor.document.uri.fsPath : '';
|
const filePath = editor ? editor.document.uri.fsPath : '';
|
||||||
|
@ -28,7 +30,10 @@ function sendActiveFileChangedNotification(
|
||||||
const notification: JSONRPCNotification = {
|
const notification: JSONRPCNotification = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
method: 'ide/activeFileChanged',
|
method: 'ide/activeFileChanged',
|
||||||
params: { filePath },
|
params: {
|
||||||
|
filePath,
|
||||||
|
recentOpenFiles: recentFilesManager.recentFiles,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
transport.send(notification);
|
transport.send(notification);
|
||||||
}
|
}
|
||||||
|
@ -52,9 +57,14 @@ export class IDEServer {
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
const mcpServer = createMcpServer();
|
const mcpServer = createMcpServer();
|
||||||
|
|
||||||
const disposable = vscode.window.onDidChangeActiveTextEditor((_editor) => {
|
const recentFilesManager = new RecentFilesManager(context);
|
||||||
|
const disposable = recentFilesManager.onDidChange(() => {
|
||||||
for (const transport of Object.values(transports)) {
|
for (const transport of Object.values(transports)) {
|
||||||
sendActiveFileChangedNotification(transport, this.logger);
|
sendActiveFileChangedNotification(
|
||||||
|
transport,
|
||||||
|
this.logger,
|
||||||
|
recentFilesManager,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
context.subscriptions.push(disposable);
|
context.subscriptions.push(disposable);
|
||||||
|
@ -158,7 +168,11 @@ export class IDEServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionsWithInitialNotification.has(sessionId)) {
|
if (!sessionsWithInitialNotification.has(sessionId)) {
|
||||||
sendActiveFileChangedNotification(transport, this.logger);
|
sendActiveFileChangedNotification(
|
||||||
|
transport,
|
||||||
|
this.logger,
|
||||||
|
recentFilesManager,
|
||||||
|
);
|
||||||
sessionsWithInitialNotification.add(sessionId);
|
sessionsWithInitialNotification.add(sessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import {
|
||||||
|
RecentFilesManager,
|
||||||
|
MAX_FILES,
|
||||||
|
MAX_FILE_AGE_MINUTES,
|
||||||
|
} from './recent-files-manager.js';
|
||||||
|
|
||||||
|
vi.mock('vscode', () => ({
|
||||||
|
EventEmitter: vi.fn(() => ({
|
||||||
|
event: vi.fn(),
|
||||||
|
fire: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
})),
|
||||||
|
window: {
|
||||||
|
onDidChangeActiveTextEditor: vi.fn(),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
onDidDeleteFiles: vi.fn(),
|
||||||
|
onDidCloseTextDocument: vi.fn(),
|
||||||
|
onDidRenameFiles: vi.fn(),
|
||||||
|
},
|
||||||
|
Uri: {
|
||||||
|
file: (path: string) => ({
|
||||||
|
fsPath: path,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RecentFilesManager', () => {
|
||||||
|
let context: vscode.ExtensionContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
context = {
|
||||||
|
subscriptions: [],
|
||||||
|
} as unknown as vscode.ExtensionContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a file to the list', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an existing file to the top', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri1 = vscode.Uri.file('/test/file1.txt');
|
||||||
|
const uri2 = vscode.Uri.file('/test/file2.txt');
|
||||||
|
manager.add(uri1);
|
||||||
|
manager.add(uri2);
|
||||||
|
manager.add(uri1);
|
||||||
|
expect(manager.recentFiles).toHaveLength(2);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not exceed the max number of files', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
for (let i = 0; i < MAX_FILES + 5; i++) {
|
||||||
|
const uri = vscode.Uri.file(`/test/file${i}.txt`);
|
||||||
|
manager.add(uri);
|
||||||
|
}
|
||||||
|
expect(manager.recentFiles).toHaveLength(MAX_FILES);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe(
|
||||||
|
`/test/file${MAX_FILES + 4}.txt`,
|
||||||
|
);
|
||||||
|
expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onDidChange when a file is added', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire');
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a file when it is closed', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
|
||||||
|
// Simulate closing the file
|
||||||
|
const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock
|
||||||
|
.calls[0][0];
|
||||||
|
closeHandler({ uri } as vscode.TextDocument);
|
||||||
|
|
||||||
|
expect(manager.recentFiles).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onDidChange when a file is removed', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire');
|
||||||
|
const closeHandler = vi.mocked(vscode.workspace.onDidCloseTextDocument).mock
|
||||||
|
.calls[0][0];
|
||||||
|
closeHandler({ uri } as vscode.TextDocument);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a file when it is deleted', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri1 = vscode.Uri.file('/test/file1.txt');
|
||||||
|
const uri2 = vscode.Uri.file('/test/file2.txt');
|
||||||
|
manager.add(uri1);
|
||||||
|
manager.add(uri2);
|
||||||
|
expect(manager.recentFiles).toHaveLength(2);
|
||||||
|
|
||||||
|
// Simulate deleting a file
|
||||||
|
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock
|
||||||
|
.calls[0][0];
|
||||||
|
deleteHandler({ files: [uri1] });
|
||||||
|
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onDidChange when a file is deleted', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire');
|
||||||
|
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock
|
||||||
|
.calls[0][0];
|
||||||
|
deleteHandler({ files: [uri] });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes multiple files when they are deleted', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri1 = vscode.Uri.file('/test/file1.txt');
|
||||||
|
const uri2 = vscode.Uri.file('/test/file2.txt');
|
||||||
|
const uri3 = vscode.Uri.file('/test/file3.txt');
|
||||||
|
manager.add(uri1);
|
||||||
|
manager.add(uri2);
|
||||||
|
manager.add(uri3);
|
||||||
|
expect(manager.recentFiles).toHaveLength(3);
|
||||||
|
|
||||||
|
// Simulate deleting multiple files
|
||||||
|
const deleteHandler = vi.mocked(vscode.workspace.onDidDeleteFiles).mock
|
||||||
|
.calls[0][0];
|
||||||
|
deleteHandler({ files: [uri1, uri3] });
|
||||||
|
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes files older than the max age', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri1 = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri1);
|
||||||
|
|
||||||
|
// Advance time by more than the max age
|
||||||
|
const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000;
|
||||||
|
vi.advanceTimersByTime(twoMinutesMs);
|
||||||
|
|
||||||
|
const uri2 = vscode.Uri.file('/test/file2.txt');
|
||||||
|
manager.add(uri2);
|
||||||
|
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onDidChange only once when adding an existing file', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const uri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
manager.add(uri);
|
||||||
|
|
||||||
|
const spy = vi.spyOn(manager['onDidChangeEmitter'], 'fire');
|
||||||
|
manager.add(uri);
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the file when it is renamed', () => {
|
||||||
|
const manager = new RecentFilesManager(context);
|
||||||
|
const oldUri = vscode.Uri.file('/test/file1.txt');
|
||||||
|
const newUri = vscode.Uri.file('/test/file2.txt');
|
||||||
|
manager.add(oldUri);
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||||
|
|
||||||
|
// Simulate renaming the file
|
||||||
|
const renameHandler = vi.mocked(vscode.workspace.onDidRenameFiles).mock
|
||||||
|
.calls[0][0];
|
||||||
|
renameHandler({ files: [{ oldUri, newUri }] });
|
||||||
|
|
||||||
|
expect(manager.recentFiles).toHaveLength(1);
|
||||||
|
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
|
||||||
|
export const MAX_FILES = 10;
|
||||||
|
export const MAX_FILE_AGE_MINUTES = 5;
|
||||||
|
|
||||||
|
interface RecentFile {
|
||||||
|
uri: vscode.Uri;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps track of the 10 most recently-opened files
|
||||||
|
* opened less than 5 ago. If a file is closed or deleted,
|
||||||
|
* it will be removed. If the length is maxxed out,
|
||||||
|
* the now-removed file will not be replaced by an older file.
|
||||||
|
*/
|
||||||
|
export class RecentFilesManager {
|
||||||
|
private readonly files: RecentFile[] = [];
|
||||||
|
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||||
|
|
||||||
|
constructor(private readonly context: vscode.ExtensionContext) {
|
||||||
|
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
|
||||||
|
(editor) => {
|
||||||
|
if (editor) {
|
||||||
|
this.add(editor.document.uri);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const fileWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
||||||
|
for (const uri of event.files) {
|
||||||
|
this.remove(uri);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
|
this.remove(document.uri);
|
||||||
|
});
|
||||||
|
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
||||||
|
for (const { oldUri, newUri } of event.files) {
|
||||||
|
this.remove(oldUri, false);
|
||||||
|
this.add(newUri);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
context.subscriptions.push(
|
||||||
|
editorWatcher,
|
||||||
|
fileWatcher,
|
||||||
|
closeWatcher,
|
||||||
|
renameWatcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(uri: vscode.Uri, fireEvent = true) {
|
||||||
|
const index = this.files.findIndex(
|
||||||
|
(file) => file.uri.fsPath === uri.fsPath,
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.files.splice(index, 1);
|
||||||
|
if (fireEvent) {
|
||||||
|
this.onDidChangeEmitter.fire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(uri: vscode.Uri) {
|
||||||
|
// Remove if it already exists to avoid duplicates and move it to the top.
|
||||||
|
this.remove(uri, false);
|
||||||
|
|
||||||
|
this.files.unshift({ uri, timestamp: Date.now() });
|
||||||
|
|
||||||
|
if (this.files.length > MAX_FILES) {
|
||||||
|
this.files.pop();
|
||||||
|
}
|
||||||
|
this.onDidChangeEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
get recentFiles(): Array<{ filePath: string; timestamp: number }> {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAgeInMs = MAX_FILE_AGE_MINUTES * 60 * 1000;
|
||||||
|
return this.files
|
||||||
|
.filter((file) => now - file.timestamp < maxAgeInMs)
|
||||||
|
.map((file) => ({
|
||||||
|
filePath: file.uri.fsPath,
|
||||||
|
timestamp: file.timestamp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "Node16",
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022", "dom"],
|
"lib": ["ES2022", "dom"],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|
Loading…
Reference in New Issue