mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -3,4 +3,5 @@
|
||||
../
|
||||
../../
|
||||
!LICENSE
|
||||
!NOTICES.txt
|
||||
!assets/
|
||||
|
||||
114
packages/vscode-ide-companion/NOTICES.txt
Normal file
114
packages/vscode-ide-companion/NOTICES.txt
Normal 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.
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
105
packages/vscode-ide-companion/scripts/generate-notices.js
Normal file
105
packages/vscode-ide-companion/scripts/generate-notices.js
Normal 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);
|
||||
228
packages/vscode-ide-companion/src/diff-manager.ts
Normal file
228
packages/vscode-ide-companion/src/diff-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
440
packages/vscode-ide-companion/src/open-files-manager.test.ts
Normal file
440
packages/vscode-ide-companion/src/open-files-manager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
packages/vscode-ide-companion/src/open-files-manager.ts
Normal file
178
packages/vscode-ide-companion/src/open-files-manager.ts
Normal 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],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
RecentFilesManager,
|
||||
MAX_FILES,
|
||||
MAX_FILE_AGE_MINUTES,
|
||||
} from './recent-files-manager.js';
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
EventEmitter: vi.fn(() => {
|
||||
const listeners: Array<(e: void) => unknown> = [];
|
||||
return {
|
||||
event: vi.fn((listener) => {
|
||||
listeners.push(listener);
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
fire: vi.fn(() => {
|
||||
listeners.forEach((listener) => listener(undefined));
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
}),
|
||||
window: {
|
||||
onDidChangeActiveTextEditor: vi.fn(),
|
||||
onDidChangeTextEditorSelection: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
onDidDeleteFiles: vi.fn(),
|
||||
onDidCloseTextDocument: vi.fn(),
|
||||
onDidRenameFiles: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
file: (path: string) => ({
|
||||
fsPath: path,
|
||||
scheme: 'file',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('RecentFilesManager', () => {
|
||||
let context: vscode.ExtensionContext;
|
||||
let onDidChangeActiveTextEditorListener: (
|
||||
editor: vscode.TextEditor | undefined,
|
||||
) => void;
|
||||
let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void;
|
||||
let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void;
|
||||
let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation(
|
||||
(listener) => {
|
||||
onDidChangeActiveTextEditorListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
);
|
||||
vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation(
|
||||
(listener) => {
|
||||
onDidDeleteFilesListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
);
|
||||
vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation(
|
||||
(listener) => {
|
||||
onDidCloseTextDocumentListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
);
|
||||
vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation(
|
||||
(listener) => {
|
||||
onDidRenameFilesListener = listener;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
);
|
||||
|
||||
context = {
|
||||
subscriptions: [],
|
||||
} as unknown as vscode.ExtensionContext;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const getUri = (path: string) =>
|
||||
vscode.Uri.file(path) as unknown as vscode.Uri;
|
||||
|
||||
it('adds a file to the list', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||
});
|
||||
|
||||
it('moves an existing file to the top', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri1 = getUri('/test/file1.txt');
|
||||
const uri2 = getUri('/test/file2.txt');
|
||||
manager.add(uri1);
|
||||
manager.add(uri2);
|
||||
manager.add(uri1);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(2);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||
});
|
||||
|
||||
it('does not exceed the max number of files', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
for (let i = 0; i < MAX_FILES + 5; i++) {
|
||||
const uri = getUri(`/test/file${i}.txt`);
|
||||
manager.add(uri);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(MAX_FILES);
|
||||
expect(manager.recentFiles[0].filePath).toBe(
|
||||
`/test/file${MAX_FILES + 4}.txt`,
|
||||
);
|
||||
expect(manager.recentFiles[MAX_FILES - 1].filePath).toBe(`/test/file5.txt`);
|
||||
});
|
||||
|
||||
it('fires onDidChange when a file is added', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const onDidChangeSpy = vi.fn();
|
||||
manager.onDidChange(onDidChangeSpy);
|
||||
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onDidChangeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a file when it is closed', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
|
||||
onDidCloseTextDocumentListener({ uri } as vscode.TextDocument);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fires onDidChange when a file is removed', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const onDidChangeSpy = vi.fn();
|
||||
manager.onDidChange(onDidChangeSpy);
|
||||
|
||||
onDidCloseTextDocumentListener({ uri } as vscode.TextDocument);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(onDidChangeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a file when it is deleted', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri1 = getUri('/test/file1.txt');
|
||||
const uri2 = getUri('/test/file2.txt');
|
||||
manager.add(uri1);
|
||||
manager.add(uri2);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(2);
|
||||
|
||||
onDidDeleteFilesListener({ files: [uri1] });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||
});
|
||||
|
||||
it('fires onDidChange when a file is deleted', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const onDidChangeSpy = vi.fn();
|
||||
manager.onDidChange(onDidChangeSpy);
|
||||
|
||||
onDidDeleteFilesListener({ files: [uri] });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(onDidChangeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes multiple files when they are deleted', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri1 = getUri('/test/file1.txt');
|
||||
const uri2 = getUri('/test/file2.txt');
|
||||
const uri3 = getUri('/test/file3.txt');
|
||||
manager.add(uri1);
|
||||
manager.add(uri2);
|
||||
manager.add(uri3);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(3);
|
||||
|
||||
onDidDeleteFilesListener({ files: [uri1, uri3] });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||
});
|
||||
|
||||
it('prunes files older than the max age', () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri1 = getUri('/test/file1.txt');
|
||||
manager.add(uri1);
|
||||
|
||||
// Advance time by more than the max age
|
||||
const twoMinutesMs = (MAX_FILE_AGE_MINUTES + 1) * 60 * 1000;
|
||||
vi.advanceTimersByTime(twoMinutesMs);
|
||||
|
||||
const uri2 = getUri('/test/file2.txt');
|
||||
manager.add(uri2);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||
});
|
||||
|
||||
it('fires onDidChange only once when adding an existing file', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
const onDidChangeSpy = vi.fn();
|
||||
manager.onDidChange(onDidChangeSpy);
|
||||
|
||||
manager.add(uri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(onDidChangeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the file when it is renamed', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const oldUri = getUri('/test/file1.txt');
|
||||
const newUri = getUri('/test/file2.txt');
|
||||
manager.add(oldUri);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||
|
||||
onDidRenameFilesListener({ files: [{ oldUri, newUri }] });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file2.txt');
|
||||
});
|
||||
|
||||
it('adds a file when the active editor changes', async () => {
|
||||
const manager = new RecentFilesManager(context);
|
||||
const uri = getUri('/test/file1.txt');
|
||||
|
||||
onDidChangeActiveTextEditorListener({
|
||||
document: { uri },
|
||||
} as vscode.TextEditor);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(manager.recentFiles).toHaveLength(1);
|
||||
expect(manager.recentFiles[0].filePath).toBe('/test/file1.txt');
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export const MAX_FILES = 10;
|
||||
export const MAX_FILE_AGE_MINUTES = 5;
|
||||
|
||||
interface RecentFile {
|
||||
uri: vscode.Uri;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of the 10 most recently-opened files
|
||||
* opened less than 5 min ago. If a file is closed or deleted,
|
||||
* it will be removed. If the max length is reached, older files will get removed first.
|
||||
*/
|
||||
export class RecentFilesManager {
|
||||
private readonly files: RecentFile[] = [];
|
||||
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
private debounceTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
const editorWatcher = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
if (editor) {
|
||||
this.add(editor.document.uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
||||
for (const uri of event.files) {
|
||||
this.remove(uri);
|
||||
}
|
||||
});
|
||||
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
this.remove(document.uri);
|
||||
});
|
||||
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
||||
for (const { oldUri, newUri } of event.files) {
|
||||
this.remove(oldUri, false);
|
||||
this.add(newUri);
|
||||
}
|
||||
});
|
||||
|
||||
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection(
|
||||
() => {
|
||||
this.fireWithDebounce();
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
editorWatcher,
|
||||
deleteWatcher,
|
||||
closeWatcher,
|
||||
renameWatcher,
|
||||
selectionWatcher,
|
||||
);
|
||||
}
|
||||
|
||||
private fireWithDebounce() {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}, 50); // 50ms
|
||||
}
|
||||
|
||||
private remove(uri: vscode.Uri, fireEvent = true) {
|
||||
const index = this.files.findIndex(
|
||||
(file) => file.uri.fsPath === uri.fsPath,
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.files.splice(index, 1);
|
||||
if (fireEvent) {
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(uri: vscode.Uri) {
|
||||
if (uri.scheme !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.remove(uri, false);
|
||||
this.files.unshift({ uri, timestamp: Date.now() });
|
||||
|
||||
if (this.files.length > MAX_FILES) {
|
||||
this.files.pop();
|
||||
}
|
||||
this.fireWithDebounce();
|
||||
}
|
||||
|
||||
get recentFiles(): Array<{ filePath: string; timestamp: number }> {
|
||||
const now = Date.now();
|
||||
const maxAgeInMs = MAX_FILE_AGE_MINUTES * 60 * 1000;
|
||||
return this.files
|
||||
.filter((file) => now - file.timestamp < maxAgeInMs)
|
||||
.map((file) => ({
|
||||
filePath: file.uri.fsPath,
|
||||
timestamp: file.timestamp,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user