sync gemini-cli 0.1.17

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Yiheng Xu
2025-08-05 16:44:06 +08:00
235 changed files with 16997 additions and 3736 deletions

View File

@@ -3,4 +3,5 @@
../
../../
!LICENSE
!NOTICES.txt
!assets/

View File

@@ -0,0 +1,114 @@
This file contains third-party software notices and license terms.
============================================================
@modelcontextprotocol/sdk@^1.15.1
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
MIT License
Copyright (c) 2024 Anthropic, PBC
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
cors@^2.8.5
(No repository found)
(The MIT License)
Copyright (c) 2013 Troy Goode <troygoode@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
express@^5.1.0
(No repository found)
(The MIT License)
Copyright (c) 2009-2014 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2013-2014 Roman Shtylman <shtylman+expressjs@gmail.com>
Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
zod@^3.25.76
(git+https://github.com/colinhacks/zod.git)
MIT License
Copyright (c) 2025 Colin McDonnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
const esbuild = require('esbuild');
import esbuild from 'esbuild';
const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
@@ -40,7 +40,7 @@ async function main() {
sourcemap: !production,
sourcesContent: false,
platform: 'node',
outfile: 'dist/extension.js',
outfile: 'dist/extension.cjs',
external: ['vscode'],
logLevel: 'silent',
plugins: [

View File

@@ -31,22 +31,79 @@
"onStartupFinished"
],
"contributes": {
"languages": [
{
"id": "qwen-diff-editable"
}
],
"commands": [
{
"command": "qwen-code.runQwenCode",
"command": "qwen.diff.accept",
"title": "Qwen Code: Accept Current Diff",
"icon": "$(check)"
},
{
"command": "qwen.diff.cancel",
"title": "Cancel",
"icon": "$(close)"
},
{
"command": "qwen-code.runGeminiCLI",
"title": "Qwen Code: Run"
},
{
"command": "qwen-code.showNotices",
"title": "Qwen Code: View Third-Party Notices"
}
],
"menus": {
"commandPalette": [
{
"command": "qwen.diff.accept",
"when": "qwen.diff.isVisible"
},
{
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible"
}
],
"editor/title": [
{
"command": "qwen.diff.accept",
"when": "qwen.diff.isVisible",
"group": "navigation"
},
{
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible",
"group": "navigation"
}
]
},
"keybindings": [
{
"command": "qwen.diff.accept",
"key": "ctrl+s",
"when": "qwen.diff.isVisible"
},
{
"command": "qwen.diff.accept",
"key": "cmd+s",
"when": "qwen.diff.isVisible"
}
]
},
"main": "./dist/extension.js",
"main": "./dist/extension.cjs",
"type": "module",
"scripts": {
"vscode:prepublish": "npm run check-types && npm run lint && node esbuild.js --production",
"vscode:prepublish": "npm run generate:notices && npm run check-types && npm run lint && node esbuild.js --production",
"build": "npm run compile",
"compile": "npm run check-types && npm run lint && node esbuild.js",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "vsce package --no-dependencies",
"generate:notices": "node ./scripts/generate-notices.js",
"check-types": "tsc --noEmit",
"lint": "eslint src",
"test": "vitest run",

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const projectRoot = path.resolve(
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'),
);
const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion');
const noticeFilePath = path.join(packagePath, 'NOTICES.txt');
async function getDependencyLicense(depName, depVersion) {
let depPackageJsonPath;
let licenseContent = 'License text not found.';
let repositoryUrl = 'No repository found';
try {
depPackageJsonPath = path.join(
projectRoot,
'node_modules',
depName,
'package.json',
);
if (!(await fs.stat(depPackageJsonPath).catch(() => false))) {
depPackageJsonPath = path.join(
packagePath,
'node_modules',
depName,
'package.json',
);
}
const depPackageJsonContent = await fs.readFile(
depPackageJsonPath,
'utf-8',
);
const depPackageJson = JSON.parse(depPackageJsonContent);
repositoryUrl = depPackageJson.repository?.url || repositoryUrl;
const licenseFile = depPackageJson.licenseFile
? path.join(path.dirname(depPackageJsonPath), depPackageJson.licenseFile)
: path.join(path.dirname(depPackageJsonPath), 'LICENSE');
try {
licenseContent = await fs.readFile(licenseFile, 'utf-8');
} catch (e) {
console.warn(
`Warning: Failed to read license file for ${depName}: ${e.message}`,
);
}
} catch (e) {
console.warn(
`Warning: Could not find package.json for ${depName}: ${e.message}`,
);
}
return {
name: depName,
version: depVersion,
repository: repositoryUrl,
license: licenseContent,
};
}
async function main() {
try {
const packageJsonPath = path.join(packagePath, 'package.json');
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
const dependencies = packageJson.dependencies || {};
const dependencyEntries = Object.entries(dependencies);
const licensePromises = dependencyEntries.map(([depName, depVersion]) =>
getDependencyLicense(depName, depVersion),
);
const dependencyLicenses = await Promise.all(licensePromises);
let noticeText =
'This file contains third-party software notices and license terms.\n\n';
for (const dep of dependencyLicenses) {
noticeText +=
'============================================================\n';
noticeText += `${dep.name}@${dep.version}\n`;
noticeText += `(${dep.repository})\n\n`;
noticeText += `${dep.license}\n\n`;
}
await fs.writeFile(noticeFilePath, noticeText);
console.log(`NOTICES.txt generated at ${noticeFilePath}`);
} catch (error) {
console.error('Error generating NOTICES.txt:', error);
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import * as path from 'node:path';
import { DIFF_SCHEME } from './extension.js';
import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
private content = new Map<string, string>();
private onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
get onDidChange(): vscode.Event<vscode.Uri> {
return this.onDidChangeEmitter.event;
}
provideTextDocumentContent(uri: vscode.Uri): string {
return this.content.get(uri.toString()) ?? '';
}
setContent(uri: vscode.Uri, content: string): void {
this.content.set(uri.toString(), content);
this.onDidChangeEmitter.fire(uri);
}
deleteContent(uri: vscode.Uri): void {
this.content.delete(uri.toString());
}
getContent(uri: vscode.Uri): string | undefined {
return this.content.get(uri.toString());
}
}
// Information about a diff view that is currently open.
interface DiffInfo {
originalFilePath: string;
newContent: string;
rightDocUri: vscode.Uri;
}
/**
* Manages the state and lifecycle of diff views within the IDE.
*/
export class DiffManager {
private readonly onDidChangeEmitter =
new vscode.EventEmitter<JSONRPCNotification>();
readonly onDidChange = this.onDidChangeEmitter.event;
private diffDocuments = new Map<string, DiffInfo>();
constructor(
private readonly logger: vscode.OutputChannel,
private readonly diffContentProvider: DiffContentProvider,
) {}
/**
* Creates and shows a new diff view.
*/
async showDiff(filePath: string, newContent: string) {
const fileUri = vscode.Uri.file(filePath);
const rightDocUri = vscode.Uri.from({
scheme: DIFF_SCHEME,
path: filePath,
// cache busting
query: `rand=${Math.random()}`,
});
this.diffContentProvider.setContent(rightDocUri, newContent);
this.addDiffDocument(rightDocUri, {
originalFilePath: filePath,
newContent,
rightDocUri,
});
const diffTitle = `${path.basename(filePath)} ↔ Modified`;
await vscode.commands.executeCommand(
'setContext',
'gemini.diff.isVisible',
true,
);
let leftDocUri;
try {
await vscode.workspace.fs.stat(fileUri);
leftDocUri = fileUri;
} catch {
// We need to provide an empty document to diff against.
// Using the 'untitled' scheme is one way to do this.
leftDocUri = vscode.Uri.from({
scheme: 'untitled',
path: filePath,
});
}
await vscode.commands.executeCommand(
'vscode.diff',
leftDocUri,
rightDocUri,
diffTitle,
{
preview: false,
},
);
await vscode.commands.executeCommand(
'workbench.action.files.setActiveEditorWriteableInSession',
);
}
/**
* Closes an open diff view for a specific file.
*/
async closeDiff(filePath: string) {
let uriToClose: vscode.Uri | undefined;
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === filePath) {
uriToClose = vscode.Uri.parse(uriString);
break;
}
}
if (uriToClose) {
const rightDoc = await vscode.workspace.openTextDocument(uriToClose);
const modifiedContent = rightDoc.getText();
await this.closeDiffEditor(uriToClose);
this.onDidChangeEmitter.fire({
jsonrpc: '2.0',
method: 'ide/diffClosed',
params: {
filePath,
content: modifiedContent,
},
});
vscode.window.showInformationMessage(`Diff for ${filePath} closed.`);
} else {
vscode.window.showWarningMessage(`No open diff found for ${filePath}.`);
}
}
/**
* User accepts the changes in a diff view. Does not apply changes.
*/
async acceptDiff(rightDocUri: vscode.Uri) {
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
if (!diffInfo) {
this.logger.appendLine(
`No diff info found for ${rightDocUri.toString()}`,
);
return;
}
const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
const modifiedContent = rightDoc.getText();
await this.closeDiffEditor(rightDocUri);
this.onDidChangeEmitter.fire({
jsonrpc: '2.0',
method: 'ide/diffAccepted',
params: {
filePath: diffInfo.originalFilePath,
content: modifiedContent,
},
});
}
/**
* Called when a user cancels a diff view.
*/
async cancelDiff(rightDocUri: vscode.Uri) {
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
if (!diffInfo) {
this.logger.appendLine(
`No diff info found for ${rightDocUri.toString()}`,
);
// Even if we don't have diff info, we should still close the editor.
await this.closeDiffEditor(rightDocUri);
return;
}
const rightDoc = await vscode.workspace.openTextDocument(rightDocUri);
const modifiedContent = rightDoc.getText();
await this.closeDiffEditor(rightDocUri);
this.onDidChangeEmitter.fire({
jsonrpc: '2.0',
method: 'ide/diffClosed',
params: {
filePath: diffInfo.originalFilePath,
content: modifiedContent,
},
});
}
private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) {
this.diffDocuments.set(uri.toString(), diffInfo);
}
private async closeDiffEditor(rightDocUri: vscode.Uri) {
const diffInfo = this.diffDocuments.get(rightDocUri.toString());
await vscode.commands.executeCommand(
'setContext',
'gemini.diff.isVisible',
false,
);
if (diffInfo) {
this.diffDocuments.delete(rightDocUri.toString());
this.diffContentProvider.deleteContent(rightDocUri);
}
// Find and close the tab corresponding to the diff view
for (const tabGroup of vscode.window.tabGroups.all) {
for (const tab of tabGroup.tabs) {
const input = tab.input as {
modified?: vscode.Uri;
original?: vscode.Uri;
};
if (input && input.modified?.toString() === rightDocUri.toString()) {
await vscode.window.tabGroups.close(tab);
return;
}
}
}
}
}

View File

@@ -5,18 +5,75 @@
*/
import * as vscode from 'vscode';
import { IDEServer } from './ide-server';
import { createLogger } from './utils/logger';
import { IDEServer } from './ide-server.js';
import { DiffContentProvider, DiffManager } from './diff-manager.js';
import { createLogger } from './utils/logger.js';
const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH';
export const DIFF_SCHEME = 'gemini-diff';
let ideServer: IDEServer;
let logger: vscode.OutputChannel;
let log: (message: string) => void = () => {};
function updateWorkspacePath(context: vscode.ExtensionContext) {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders && workspaceFolders.length === 1) {
const workspaceFolder = workspaceFolders[0];
context.environmentVariableCollection.replace(
IDE_WORKSPACE_PATH_ENV_VAR,
workspaceFolder.uri.fsPath,
);
} else {
context.environmentVariableCollection.replace(
IDE_WORKSPACE_PATH_ENV_VAR,
'',
);
}
}
export async function activate(context: vscode.ExtensionContext) {
logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion');
log = createLogger(context, logger);
log('Extension activated');
ideServer = new IDEServer(log);
updateWorkspacePath(context);
const diffContentProvider = new DiffContentProvider();
const diffManager = new DiffManager(logger, diffContentProvider);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
if (doc.uri.scheme === DIFF_SCHEME) {
diffManager.cancelDiff(doc.uri);
}
}),
vscode.workspace.registerTextDocumentContentProvider(
DIFF_SCHEME,
diffContentProvider,
),
vscode.commands.registerCommand(
'gemini.diff.accept',
(uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.acceptDiff(docUri);
}
},
),
vscode.commands.registerCommand(
'gemini.diff.cancel',
(uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.cancelDiff(docUri);
}
},
),
);
ideServer = new IDEServer(log, diffManager);
try {
await ideServer.start(context);
} catch (err) {
@@ -25,12 +82,22 @@ export async function activate(context: vscode.ExtensionContext) {
}
context.subscriptions.push(
vscode.workspace.onDidChangeWorkspaceFolders(() => {
updateWorkspacePath(context);
}),
vscode.commands.registerCommand('gemini-cli.runGeminiCLI', () => {
const geminiCmd = 'gemini';
const terminal = vscode.window.createTerminal(`Gemini CLI`);
terminal.show();
terminal.sendText(geminiCmd);
}),
vscode.commands.registerCommand('gemini-cli.showNotices', async () => {
const noticePath = vscode.Uri.joinPath(
context.extensionUri,
'NOTICES.txt',
);
await vscode.window.showTextDocument(noticePath);
}),
);
}

View File

@@ -14,49 +14,27 @@ import {
type JSONRPCNotification,
} from '@modelcontextprotocol/sdk/types.js';
import { Server as HTTPServer } from 'node:http';
import { RecentFilesManager } from './recent-files-manager.js';
import { z } from 'zod';
import { DiffManager } from './diff-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 sendOpenFilesChangedNotification(
function sendIdeContextUpdateNotification(
transport: StreamableHTTPServerTransport,
log: (message: string) => void,
recentFilesManager: RecentFilesManager,
openFilesManager: OpenFilesManager,
) {
const editor = vscode.window.activeTextEditor;
const filePath =
editor && editor.document.uri.scheme === 'file'
? editor.document.uri.fsPath
: '';
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 ideContext = openFilesManager.state;
const notification: JSONRPCNotification = {
jsonrpc: '2.0',
method: 'ide/openFilesChanged',
params: {
activeFile: filePath,
recentOpenFiles: recentFilesManager.recentFiles.filter(
(file) => file.filePath !== filePath,
),
cursor,
selectedText,
},
method: 'ide/contextUpdate',
params: ideContext,
};
log(
`Sending active file changed notification: ${JSON.stringify(
`Sending IDE context update notification: ${JSON.stringify(
notification,
null,
2,
@@ -69,32 +47,42 @@ export class IDEServer {
private server: HTTPServer | undefined;
private context: vscode.ExtensionContext | undefined;
private log: (message: string) => void;
diffManager: DiffManager;
constructor(log: (message: string) => void) {
constructor(log: (message: string) => void, diffManager: DiffManager) {
this.log = log;
this.diffManager = diffManager;
}
async start(context: vscode.ExtensionContext) {
this.context = context;
const sessionsWithInitialNotification = new Set<string>();
const transports: { [sessionId: string]: StreamableHTTPServerTransport } =
{};
const sessionsWithInitialNotification = new Set<string>();
const app = express();
app.use(express.json());
const mcpServer = createMcpServer();
const mcpServer = createMcpServer(this.diffManager);
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)) {
sendOpenFilesChangedNotification(
sendIdeContextUpdateNotification(
transport,
this.log.bind(this),
recentFilesManager,
openFilesManager,
);
}
});
context.subscriptions.push(onDidChangeSubscription);
const onDidChangeDiffSubscription = this.diffManager.onDidChange(
(notification: JSONRPCNotification) => {
for (const transport of Object.values(transports)) {
transport.send(notification);
}
},
);
context.subscriptions.push(onDidChangeDiffSubscription);
app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as
@@ -112,7 +100,6 @@ export class IDEServer {
transports[newSessionId] = transport;
},
});
const keepAlive = setInterval(() => {
try {
transport.send({ jsonrpc: '2.0', method: 'ping' });
@@ -191,10 +178,10 @@ export class IDEServer {
}
if (!sessionsWithInitialNotification.has(sessionId)) {
sendOpenFilesChangedNotification(
sendIdeContextUpdateNotification(
transport,
this.log.bind(this),
recentFilesManager,
openFilesManager,
);
sessionsWithInitialNotification.add(sessionId);
}
@@ -236,7 +223,7 @@ export class IDEServer {
}
}
const createMcpServer = () => {
const createMcpServer = (diffManager: DiffManager) => {
const server = new McpServer(
{
name: 'gemini-cli-companion-mcp-server',
@@ -244,5 +231,54 @@ const createMcpServer = () => {
},
{ capabilities: { logging: {} } },
);
server.registerTool(
'openDiff',
{
description:
'(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejcted.',
inputSchema: z.object({
filePath: z.string(),
// TODO(chrstn): determine if this should be required or not.
newContent: z.string().optional(),
}).shape,
},
async ({
filePath,
newContent,
}: {
filePath: string;
newContent?: string;
}) => {
await diffManager.showDiff(filePath, newContent ?? '');
return {
content: [
{
type: 'text',
text: `Showing diff for ${filePath}`,
},
],
};
},
);
server.registerTool(
'closeDiff',
{
description: '(IDE Tool) Close an open diff view for a specific file.',
inputSchema: z.object({
filePath: z.string(),
}).shape,
},
async ({ filePath }: { filePath: string }) => {
await diffManager.closeDiff(filePath);
return {
content: [
{
type: 'text',
text: `Closed diff for ${filePath}`,
},
],
};
},
);
return server;
};

View File

@@ -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);
});
});

View File

@@ -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 '@qwen-code/qwen-code-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],
},
};
}
}

View File

@@ -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');
});
});

View File

@@ -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,
}));
}
}