Merge branch 'main' into dev/dmitriv/finalize-button-location-api

This commit is contained in:
Dmitriy Vasyura
2026-01-05 00:26:59 -08:00
committed by GitHub
83 changed files with 2758 additions and 846 deletions
-2
View File
@@ -2,8 +2,6 @@
// --- Chat ---
"inlineChat.enableV2": true,
"chat.tools.terminal.autoApprove": {
"/^npm (test|lint|run compile)\\b/": true,
"/^npx tsc\\b.*--noEmit/": true,
"scripts/test.bat": true,
"scripts/test.sh": true,
"scripts/test-integration.bat": true,
+18 -6
View File
@@ -1924,7 +1924,15 @@ export class Repository {
async stage(path: string, data: Uint8Array): Promise<void> {
const relativePath = this.sanitizeRelativePath(path);
const child = this.stream(['hash-object', '--stdin', '-w', '--path', relativePath], { stdio: [null, null, null] });
child.stdin!.end(data);
if (!child.stdin) {
throw new GitError({
message: 'Failed to spawn git process',
exitCode: -1
});
}
child.stdin.end(data);
const { exitCode, stdout } = await exec(child);
const hash = stdout.toString('utf8');
@@ -2684,19 +2692,23 @@ export class Repository {
if (limit !== 0 && parser.status.length > limit) {
child.removeListener('close', onClose);
child.stdout!.removeListener('data', onStdoutData);
child.stdout?.removeListener('data', onStdoutData);
child.kill();
c({ status: parser.status.slice(0, limit), statusLength: parser.status.length, didHitLimit: true });
}
};
child.stdout!.setEncoding('utf8');
child.stdout!.on('data', onStdoutData);
if (child.stdout) {
child.stdout.setEncoding('utf8');
child.stdout.on('data', onStdoutData);
}
const stderrData: string[] = [];
child.stderr!.setEncoding('utf8');
child.stderr!.on('data', raw => stderrData.push(raw as string));
if (child.stderr) {
child.stderr.setEncoding('utf8');
child.stderr.on('data', raw => stderrData.push(raw as string));
}
child.on('error', cpErrorHandler(e));
child.on('close', onClose);
+17 -5
View File
@@ -2347,7 +2347,15 @@ export class Repository implements Disposable {
// https://git-scm.com/docs/git-check-ignore#git-check-ignore--z
const child = this.repository.stream(['check-ignore', '-v', '-z', '--stdin'], { stdio: [null, null, null] });
child.stdin!.end(filePaths.join('\0'), 'utf8');
if (!child.stdin) {
return reject(new GitError({
message: 'Failed to spawn git process',
exitCode: -1
}));
}
child.stdin.end(filePaths.join('\0'), 'utf8');
const onExit = (exitCode: number) => {
if (exitCode === 1) {
@@ -2369,12 +2377,16 @@ export class Repository implements Disposable {
data += raw;
};
child.stdout!.setEncoding('utf8');
child.stdout!.on('data', onStdoutData);
if (child.stdout) {
child.stdout.setEncoding('utf8');
child.stdout.on('data', onStdoutData);
}
let stderr: string = '';
child.stderr!.setEncoding('utf8');
child.stderr!.on('data', raw => stderr += raw);
if (child.stderr) {
child.stderr.setEncoding('utf8');
child.stderr.on('data', raw => stderr += raw);
}
child.on('error', reject);
child.on('exit', onExit);
+48 -48
View File
@@ -27,16 +27,16 @@
"@vscode/windows-mutex": "^0.5.0",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/headless": "^6.1.0-beta.56",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/headless": "^6.1.0-beta.77",
"@xterm/xterm": "^6.1.0-beta.77",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"jschardet": "3.1.4",
@@ -3511,30 +3511,30 @@
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.56.tgz",
"integrity": "sha512-NHwA74cC+WAXJkcF/pQXHZFalGdhcUXefmatRJkRjiRGAwjn+JZYDATEnkkdgiGEWagjegalyRSBh+++hogvtQ==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.77.tgz",
"integrity": "sha512-wI5PTmsdBWEsXY/odiFwBnLHCDD5bTYI9/ix4hphuAU/E6DS9T4vQPxv5BvW9i/QJ9T1/FRPtzR54xwbJ4+eag==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.56.tgz",
"integrity": "sha512-ERc6kHXjGRbn20aqvrROx2xx+Mc5uVWpQnesMJuncnsDodEDTiunEyaG2VdBpEnlDW0xfnDA358ZQHnqzRE3SA==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.77.tgz",
"integrity": "sha512-85lYTKFgkXpMVwfSiFpdgXp4cNREr8BAIImFbnovZcmFdqDQn4QoiOywORXYos6on52oeFAyuPVEEa/GMHuHZw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.56.tgz",
"integrity": "sha512-nRVvfaJYAdxd2chk8aEYdWPJvcBo/GojeH8LRV2cuJYWUBmw7wc9USktE+K56WKEAYPPmy+l3RY9WGZrjnf/PQ==",
"version": "0.11.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.77.tgz",
"integrity": "sha512-NlmHh5D4vu1fkIXvpuTQAsVTBaZh1B55UgltnSusLiLe42jrCcU8LP+1WRrgUKnsiobZ2Q5xtrV+hiDvUro09Q==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.1.0",
@@ -3544,67 +3544,67 @@
"node": ">8.0.0"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-progress": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.56.tgz",
"integrity": "sha512-AgCCqziSDuszTrUdobG5HAN1kTRrMI7AWWWajSkTPKLYUMsD9grDSYKHDSUPHz/HC19AYaOZATgu4dFZ0zcOhw==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.77.tgz",
"integrity": "sha512-duzs9Y/BSn5RyOsKdQEqQ6oeSSXlM+jxGak3KzxWwUt0b20b7UA+lHbcj0aUqTekER1oblCJf4PgJXMMH85+hw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.56.tgz",
"integrity": "sha512-VQOIBrFUaUaM/ChqriNJW1k6TmgxsbeAppURsYV78+g2qEv5YtQKGDyEeR0yDb+UxF1t3bYeDaiOvg2AbFK9Yw==",
"version": "0.17.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.77.tgz",
"integrity": "sha512-GZ/B/ox/JwQTkuak6lba3JvqvEW1GaJ1p/e+WdYlyC343H7rxkls1VvA+y4KajFw+8WWYSIJn6KtvfNH+IKLww==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.15.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.56.tgz",
"integrity": "sha512-nn7HB0A5e6pZAGjmgE+Uur3GNtGM29XdAjm+vsaACaYlNwyMsYt2wBf/wklHH9TTReKLsag/RWawglrtMXP7HQ==",
"version": "0.15.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.77.tgz",
"integrity": "sha512-wNzJvUr2j/6p7VqBnhHSHhnimih7TE00RCPQtNo5JnWE1g7dx1ftDvojEW4X7g+Hz/TFCSCJ/JxNaps7W7Ymhw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.56.tgz",
"integrity": "sha512-6mMqs/5xYD5zMFKtMvHy5hhMHzHt4TXeoYE59G42Ac2w6vi7PNEAaEWYNd4Y4/OcR6cuIFLvzj/nlrXmxrBpqg==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.77.tgz",
"integrity": "sha512-T1XgoL1ML5fE0+nSb6bmIPHUN6Xw8X+7iQhhFa32MWDRiSqzSFYxWTs+f/zPCxXopKoL2GZEt9BXItaXMP4apQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.55",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.55.tgz",
"integrity": "sha512-F/QMnRffPSmT9SaKl01dVNDHhjQov0msxKyWQrcgGbkyGOlIHDnHn11rmgtWrD63w/zyLa3DFcLKx++kbPV99g==",
"version": "0.20.0-beta.76",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.76.tgz",
"integrity": "sha512-7E9eBZMe8zn8pJPrnKuJfTpm3s55mJpch6h7TnqvTHzHBVM0eTMSTz/s7CfMPzEKWQ/i4JIfnIRuWXys6UF2qA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/headless": {
"version": "6.1.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.56.tgz",
"integrity": "sha512-9PfYeTlJHxuAA2MiaNRj/aEmGE7RL4GxO0rZKudkPaG2koy/mcLsrP5yI+jSt3ylXkzYpHVEBKz4rY9cHZlOWQ==",
"version": "6.1.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.77.tgz",
"integrity": "sha512-meZRyQMlOIAzVYBj9QzOp7lBNPSOCQmec/c5kbcJeeWjbF89OOh1LO7775QUr0oUB0mBYdAEGNxQlRH8fL0L3Q==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.1.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.56.tgz",
"integrity": "sha512-lpgFqA8R6UWsULseccXMN5FUEKe43i/9SC2+BDROIup6ZVhqDkyCfWf4AqRaoaF1swD4yhe5opdMsnrRryCFUQ==",
"version": "6.1.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.77.tgz",
"integrity": "sha512-aAZhkdX+9VENtrbs02RoG+7OAK/G5+GOfumFSJrj7dFHrkSezDmJAHNwnU0eCn23/x1IXJ6xpWCchW1MVbfn3w==",
"license": "MIT",
"workspaces": [
"addons/*"
+10 -10
View File
@@ -89,16 +89,16 @@
"@vscode/windows-mutex": "^0.5.0",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/headless": "^6.1.0-beta.56",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/headless": "^6.1.0-beta.77",
"@xterm/xterm": "^6.1.0-beta.77",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"jschardet": "3.1.4",
+48 -48
View File
@@ -20,16 +20,16 @@
"@vscode/watcher": "bpasero/watcher#8ecffb4a57df24ac3e6946aa095b9b1f14f8bba9",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/headless": "^6.1.0-beta.56",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/headless": "^6.1.0-beta.77",
"@xterm/xterm": "^6.1.0-beta.77",
"cookie": "^0.7.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
@@ -243,30 +243,30 @@
"license": "MIT"
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.56.tgz",
"integrity": "sha512-NHwA74cC+WAXJkcF/pQXHZFalGdhcUXefmatRJkRjiRGAwjn+JZYDATEnkkdgiGEWagjegalyRSBh+++hogvtQ==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.77.tgz",
"integrity": "sha512-wI5PTmsdBWEsXY/odiFwBnLHCDD5bTYI9/ix4hphuAU/E6DS9T4vQPxv5BvW9i/QJ9T1/FRPtzR54xwbJ4+eag==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.56.tgz",
"integrity": "sha512-ERc6kHXjGRbn20aqvrROx2xx+Mc5uVWpQnesMJuncnsDodEDTiunEyaG2VdBpEnlDW0xfnDA358ZQHnqzRE3SA==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.77.tgz",
"integrity": "sha512-85lYTKFgkXpMVwfSiFpdgXp4cNREr8BAIImFbnovZcmFdqDQn4QoiOywORXYos6on52oeFAyuPVEEa/GMHuHZw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.56.tgz",
"integrity": "sha512-nRVvfaJYAdxd2chk8aEYdWPJvcBo/GojeH8LRV2cuJYWUBmw7wc9USktE+K56WKEAYPPmy+l3RY9WGZrjnf/PQ==",
"version": "0.11.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.77.tgz",
"integrity": "sha512-NlmHh5D4vu1fkIXvpuTQAsVTBaZh1B55UgltnSusLiLe42jrCcU8LP+1WRrgUKnsiobZ2Q5xtrV+hiDvUro09Q==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.1.0",
@@ -276,67 +276,67 @@
"node": ">8.0.0"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-progress": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.56.tgz",
"integrity": "sha512-AgCCqziSDuszTrUdobG5HAN1kTRrMI7AWWWajSkTPKLYUMsD9grDSYKHDSUPHz/HC19AYaOZATgu4dFZ0zcOhw==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.77.tgz",
"integrity": "sha512-duzs9Y/BSn5RyOsKdQEqQ6oeSSXlM+jxGak3KzxWwUt0b20b7UA+lHbcj0aUqTekER1oblCJf4PgJXMMH85+hw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.56.tgz",
"integrity": "sha512-VQOIBrFUaUaM/ChqriNJW1k6TmgxsbeAppURsYV78+g2qEv5YtQKGDyEeR0yDb+UxF1t3bYeDaiOvg2AbFK9Yw==",
"version": "0.17.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.77.tgz",
"integrity": "sha512-GZ/B/ox/JwQTkuak6lba3JvqvEW1GaJ1p/e+WdYlyC343H7rxkls1VvA+y4KajFw+8WWYSIJn6KtvfNH+IKLww==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.15.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.56.tgz",
"integrity": "sha512-nn7HB0A5e6pZAGjmgE+Uur3GNtGM29XdAjm+vsaACaYlNwyMsYt2wBf/wklHH9TTReKLsag/RWawglrtMXP7HQ==",
"version": "0.15.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.77.tgz",
"integrity": "sha512-wNzJvUr2j/6p7VqBnhHSHhnimih7TE00RCPQtNo5JnWE1g7dx1ftDvojEW4X7g+Hz/TFCSCJ/JxNaps7W7Ymhw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.56.tgz",
"integrity": "sha512-6mMqs/5xYD5zMFKtMvHy5hhMHzHt4TXeoYE59G42Ac2w6vi7PNEAaEWYNd4Y4/OcR6cuIFLvzj/nlrXmxrBpqg==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.77.tgz",
"integrity": "sha512-T1XgoL1ML5fE0+nSb6bmIPHUN6Xw8X+7iQhhFa32MWDRiSqzSFYxWTs+f/zPCxXopKoL2GZEt9BXItaXMP4apQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.55",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.55.tgz",
"integrity": "sha512-F/QMnRffPSmT9SaKl01dVNDHhjQov0msxKyWQrcgGbkyGOlIHDnHn11rmgtWrD63w/zyLa3DFcLKx++kbPV99g==",
"version": "0.20.0-beta.76",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.76.tgz",
"integrity": "sha512-7E9eBZMe8zn8pJPrnKuJfTpm3s55mJpch6h7TnqvTHzHBVM0eTMSTz/s7CfMPzEKWQ/i4JIfnIRuWXys6UF2qA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/headless": {
"version": "6.1.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.56.tgz",
"integrity": "sha512-9PfYeTlJHxuAA2MiaNRj/aEmGE7RL4GxO0rZKudkPaG2koy/mcLsrP5yI+jSt3ylXkzYpHVEBKz4rY9cHZlOWQ==",
"version": "6.1.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.77.tgz",
"integrity": "sha512-meZRyQMlOIAzVYBj9QzOp7lBNPSOCQmec/c5kbcJeeWjbF89OOh1LO7775QUr0oUB0mBYdAEGNxQlRH8fL0L3Q==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.1.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.56.tgz",
"integrity": "sha512-lpgFqA8R6UWsULseccXMN5FUEKe43i/9SC2+BDROIup6ZVhqDkyCfWf4AqRaoaF1swD4yhe5opdMsnrRryCFUQ==",
"version": "6.1.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.77.tgz",
"integrity": "sha512-aAZhkdX+9VENtrbs02RoG+7OAK/G5+GOfumFSJrj7dFHrkSezDmJAHNwnU0eCn23/x1IXJ6xpWCchW1MVbfn3w==",
"license": "MIT",
"workspaces": [
"addons/*"
+10 -10
View File
@@ -15,16 +15,16 @@
"@vscode/watcher": "bpasero/watcher#8ecffb4a57df24ac3e6946aa095b9b1f14f8bba9",
"@vscode/windows-process-tree": "^0.6.0",
"@vscode/windows-registry": "^1.1.0",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/headless": "^6.1.0-beta.56",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/headless": "^6.1.0-beta.77",
"@xterm/xterm": "^6.1.0-beta.77",
"cookie": "^0.7.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
+44 -44
View File
@@ -13,15 +13,15 @@
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/tree-sitter-wasm": "^0.3.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/xterm": "^6.1.0-beta.77",
"jschardet": "3.1.4",
"katex": "^0.16.22",
"tas-client": "0.3.1",
@@ -92,30 +92,30 @@
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.56.tgz",
"integrity": "sha512-NHwA74cC+WAXJkcF/pQXHZFalGdhcUXefmatRJkRjiRGAwjn+JZYDATEnkkdgiGEWagjegalyRSBh+++hogvtQ==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.77.tgz",
"integrity": "sha512-wI5PTmsdBWEsXY/odiFwBnLHCDD5bTYI9/ix4hphuAU/E6DS9T4vQPxv5BvW9i/QJ9T1/FRPtzR54xwbJ4+eag==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.56.tgz",
"integrity": "sha512-ERc6kHXjGRbn20aqvrROx2xx+Mc5uVWpQnesMJuncnsDodEDTiunEyaG2VdBpEnlDW0xfnDA358ZQHnqzRE3SA==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.77.tgz",
"integrity": "sha512-85lYTKFgkXpMVwfSiFpdgXp4cNREr8BAIImFbnovZcmFdqDQn4QoiOywORXYos6on52oeFAyuPVEEa/GMHuHZw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.56.tgz",
"integrity": "sha512-nRVvfaJYAdxd2chk8aEYdWPJvcBo/GojeH8LRV2cuJYWUBmw7wc9USktE+K56WKEAYPPmy+l3RY9WGZrjnf/PQ==",
"version": "0.11.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.77.tgz",
"integrity": "sha512-NlmHh5D4vu1fkIXvpuTQAsVTBaZh1B55UgltnSusLiLe42jrCcU8LP+1WRrgUKnsiobZ2Q5xtrV+hiDvUro09Q==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.1.0",
@@ -125,58 +125,58 @@
"node": ">8.0.0"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-progress": {
"version": "0.3.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.56.tgz",
"integrity": "sha512-AgCCqziSDuszTrUdobG5HAN1kTRrMI7AWWWajSkTPKLYUMsD9grDSYKHDSUPHz/HC19AYaOZATgu4dFZ0zcOhw==",
"version": "0.3.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.77.tgz",
"integrity": "sha512-duzs9Y/BSn5RyOsKdQEqQ6oeSSXlM+jxGak3KzxWwUt0b20b7UA+lHbcj0aUqTekER1oblCJf4PgJXMMH85+hw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.56.tgz",
"integrity": "sha512-VQOIBrFUaUaM/ChqriNJW1k6TmgxsbeAppURsYV78+g2qEv5YtQKGDyEeR0yDb+UxF1t3bYeDaiOvg2AbFK9Yw==",
"version": "0.17.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.77.tgz",
"integrity": "sha512-GZ/B/ox/JwQTkuak6lba3JvqvEW1GaJ1p/e+WdYlyC343H7rxkls1VvA+y4KajFw+8WWYSIJn6KtvfNH+IKLww==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-serialize": {
"version": "0.15.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.56.tgz",
"integrity": "sha512-nn7HB0A5e6pZAGjmgE+Uur3GNtGM29XdAjm+vsaACaYlNwyMsYt2wBf/wklHH9TTReKLsag/RWawglrtMXP7HQ==",
"version": "0.15.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.77.tgz",
"integrity": "sha512-wNzJvUr2j/6p7VqBnhHSHhnimih7TE00RCPQtNo5JnWE1g7dx1ftDvojEW4X7g+Hz/TFCSCJ/JxNaps7W7Ymhw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.56.tgz",
"integrity": "sha512-6mMqs/5xYD5zMFKtMvHy5hhMHzHt4TXeoYE59G42Ac2w6vi7PNEAaEWYNd4Y4/OcR6cuIFLvzj/nlrXmxrBpqg==",
"version": "0.10.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.77.tgz",
"integrity": "sha512-T1XgoL1ML5fE0+nSb6bmIPHUN6Xw8X+7iQhhFa32MWDRiSqzSFYxWTs+f/zPCxXopKoL2GZEt9BXItaXMP4apQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.55",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.55.tgz",
"integrity": "sha512-F/QMnRffPSmT9SaKl01dVNDHhjQov0msxKyWQrcgGbkyGOlIHDnHn11rmgtWrD63w/zyLa3DFcLKx++kbPV99g==",
"version": "0.20.0-beta.76",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.76.tgz",
"integrity": "sha512-7E9eBZMe8zn8pJPrnKuJfTpm3s55mJpch6h7TnqvTHzHBVM0eTMSTz/s7CfMPzEKWQ/i4JIfnIRuWXys6UF2qA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.56"
"@xterm/xterm": "^6.1.0-beta.77"
}
},
"node_modules/@xterm/xterm": {
"version": "6.1.0-beta.56",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.56.tgz",
"integrity": "sha512-lpgFqA8R6UWsULseccXMN5FUEKe43i/9SC2+BDROIup6ZVhqDkyCfWf4AqRaoaF1swD4yhe5opdMsnrRryCFUQ==",
"version": "6.1.0-beta.77",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.77.tgz",
"integrity": "sha512-aAZhkdX+9VENtrbs02RoG+7OAK/G5+GOfumFSJrj7dFHrkSezDmJAHNwnU0eCn23/x1IXJ6xpWCchW1MVbfn3w==",
"license": "MIT",
"workspaces": [
"addons/*"
+9 -9
View File
@@ -8,15 +8,15 @@
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/tree-sitter-wasm": "^0.3.0",
"@vscode/vscode-languagedetection": "1.0.21",
"@xterm/addon-clipboard": "^0.3.0-beta.56",
"@xterm/addon-image": "^0.10.0-beta.56",
"@xterm/addon-ligatures": "^0.11.0-beta.56",
"@xterm/addon-progress": "^0.3.0-beta.56",
"@xterm/addon-search": "^0.17.0-beta.56",
"@xterm/addon-serialize": "^0.15.0-beta.56",
"@xterm/addon-unicode11": "^0.10.0-beta.56",
"@xterm/addon-webgl": "^0.20.0-beta.55",
"@xterm/xterm": "^6.1.0-beta.56",
"@xterm/addon-clipboard": "^0.3.0-beta.77",
"@xterm/addon-image": "^0.10.0-beta.77",
"@xterm/addon-ligatures": "^0.11.0-beta.77",
"@xterm/addon-progress": "^0.3.0-beta.77",
"@xterm/addon-search": "^0.17.0-beta.77",
"@xterm/addon-serialize": "^0.15.0-beta.77",
"@xterm/addon-unicode11": "^0.10.0-beta.77",
"@xterm/addon-webgl": "^0.20.0-beta.76",
"@xterm/xterm": "^6.1.0-beta.77",
"jschardet": "3.1.4",
"katex": "^0.16.22",
"tas-client": "0.3.1",
+1 -1
View File
@@ -228,7 +228,7 @@ export class Separator implements IAction {
readonly tooltip: string = '';
readonly class: string = 'separator';
readonly enabled: boolean = false;
readonly checked: boolean = false;
readonly checked: undefined = undefined;
async run() { }
}
+8 -8
View File
@@ -1112,7 +1112,7 @@ export async function fetchResourceMetadata(
targetResource: string,
resourceMetadataUrl: string | undefined,
options: IFetchResourceMetadataOptions = {}
): Promise<{ metadata: IAuthorizationProtectedResourceMetadata; errors: Error[] }> {
): Promise<{ metadata: IAuthorizationProtectedResourceMetadata; discoveryUrl: string; errors: Error[] }> {
const {
sameOriginHeaders = {},
fetch: fetchImpl = fetch
@@ -1164,7 +1164,7 @@ export async function fetchResourceMetadata(
if (resourceMetadataUrl) {
try {
const metadata = await fetchPrm(resourceMetadataUrl, targetResource);
return { metadata, errors };
return { metadata, discoveryUrl: resourceMetadataUrl, errors };
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
@@ -1178,7 +1178,7 @@ export async function fetchResourceMetadata(
const pathAppendedUrl = `${rootUrl}${targetResourceUrlObj.pathname}`;
try {
const metadata = await fetchPrm(pathAppendedUrl, targetResource);
return { metadata, errors };
return { metadata, discoveryUrl: pathAppendedUrl, errors };
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
@@ -1187,7 +1187,7 @@ export async function fetchResourceMetadata(
// Finally, try root discovery
try {
const metadata = await fetchPrm(rootUrl, targetResourceUrlObj.origin);
return { metadata, errors };
return { metadata, discoveryUrl: rootUrl, errors };
} catch (e) {
errors.push(e instanceof Error ? e : new Error(String(e)));
}
@@ -1261,7 +1261,7 @@ async function getErrText(res: CommonResponse): Promise<string> {
export async function fetchAuthorizationServerMetadata(
authorizationServer: string,
options: IFetchAuthorizationServerMetadataOptions = {}
): Promise<IAuthorizationServerMetadata> {
): Promise<{ metadata: IAuthorizationServerMetadata; discoveryUrl: string; errors: Error[] }> {
const {
additionalHeaders = {},
fetch: fetchImpl = fetch
@@ -1301,7 +1301,7 @@ export async function fetchAuthorizationServerMetadata(
const pathToFetch = new URL(AUTH_SERVER_METADATA_DISCOVERY_PATH, authorizationServer).toString() + extraPath;
let metadata = await doFetch(pathToFetch);
if (metadata) {
return metadata;
return { metadata, discoveryUrl: pathToFetch, errors };
}
// Try fetching the OpenID Connect Discovery with path insertion.
@@ -1310,7 +1310,7 @@ export async function fetchAuthorizationServerMetadata(
const openidPathInsertionUrl = new URL(OPENID_CONNECT_DISCOVERY_PATH, authorizationServer).toString() + extraPath;
metadata = await doFetch(openidPathInsertionUrl);
if (metadata) {
return metadata;
return { metadata, discoveryUrl: openidPathInsertionUrl, errors };
}
// Try fetching the other discovery URL. For the openid metadata discovery
@@ -1321,7 +1321,7 @@ export async function fetchAuthorizationServerMetadata(
: authorizationServer + OPENID_CONNECT_DISCOVERY_PATH;
metadata = await doFetch(openidPathAdditionUrl);
if (metadata) {
return metadata;
return { metadata, discoveryUrl: openidPathAdditionUrl, errors };
}
// If we've tried all URLs and none worked, throw the error(s)
+68 -20
View File
@@ -880,6 +880,8 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
assert.strictEqual(fetchStub.firstCall.args[0], resourceMetadataUrl);
assert.strictEqual(fetchStub.firstCall.args[1].method, 'GET');
@@ -903,12 +905,13 @@ suite('OAuth', () => {
text: async () => JSON.stringify(expectedMetadata)
});
await fetchResourceMetadata(
const result = await fetchResourceMetadata(
targetResource,
resourceMetadataUrl,
{ fetch: fetchStub, sameOriginHeaders }
);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['Accept'], 'application/json');
assert.strictEqual(headers['X-Test-Header'], 'test-value');
@@ -931,12 +934,13 @@ suite('OAuth', () => {
text: async () => JSON.stringify(expectedMetadata)
});
await fetchResourceMetadata(
const result = await fetchResourceMetadata(
targetResource,
resourceMetadataUrl,
{ fetch: fetchStub, sameOriginHeaders }
);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['Accept'], 'application/json');
assert.strictEqual(headers['X-Test-Header'], undefined);
@@ -1024,6 +1028,7 @@ suite('OAuth', () => {
// URL normalization should handle hostname case differences
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
assert.deepStrictEqual(result.metadata, metadata);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
});
test('should throw error when response is not valid resource metadata', async () => {
@@ -1116,6 +1121,7 @@ suite('OAuth', () => {
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl);
assert.deepStrictEqual(result.metadata, metadata);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
assert.strictEqual(globalFetchStub.callCount, 1);
});
@@ -1135,12 +1141,13 @@ suite('OAuth', () => {
text: async () => JSON.stringify(metadata)
});
await fetchResourceMetadata(
const result = await fetchResourceMetadata(
targetResource,
resourceMetadataUrl,
{ fetch: fetchStub, sameOriginHeaders }
);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
// Different ports mean different origins
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['X-Test-Header'], undefined);
@@ -1162,12 +1169,13 @@ suite('OAuth', () => {
text: async () => JSON.stringify(metadata)
});
await fetchResourceMetadata(
const result = await fetchResourceMetadata(
targetResource,
resourceMetadataUrl,
{ fetch: fetchStub, sameOriginHeaders }
);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
// Different protocols mean different origins
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['X-Test-Header'], undefined);
@@ -1219,6 +1227,7 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource/api/v1');
assert.strictEqual(fetchStub.callCount, 1);
// Should try path-appended version first
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
@@ -1251,6 +1260,8 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource');
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(fetchStub.callCount, 2);
// First attempt with path
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
@@ -1299,6 +1310,7 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource');
assert.strictEqual(fetchStub.callCount, 1);
// Both URLs should be the same when path is /
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource');
@@ -1320,12 +1332,13 @@ suite('OAuth', () => {
text: async () => JSON.stringify(expectedMetadata)
});
await fetchResourceMetadata(
const result = await fetchResourceMetadata(
targetResource,
undefined,
{ fetch: fetchStub, sameOriginHeaders }
);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource/api');
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['Accept'], 'application/json');
assert.strictEqual(headers['X-Test-Header'], 'test-value');
@@ -1355,6 +1368,9 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource');
assert.strictEqual(result.errors.length, 1);
assert.ok(/Network connection failed/.test(result.errors[0].message));
assert.strictEqual(fetchStub.callCount, 2);
// First attempt with path should have thrown error
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
@@ -1532,6 +1548,8 @@ suite('OAuth', () => {
const result = await fetchResourceMetadata(targetResource, undefined, { fetch: fetchStub });
assert.deepStrictEqual(result.metadata, invalidMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource');
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(fetchStub.callCount, 2);
// Verify both URLs were tried
assert.strictEqual(fetchStub.firstCall.args[0], 'https://example.com/.well-known/oauth-protected-resource/api/v1');
@@ -1560,6 +1578,7 @@ suite('OAuth', () => {
);
assert.deepStrictEqual(result.metadata, validMetadata);
assert.strictEqual(result.discoveryUrl, resourceMetadataUrl);
assert.strictEqual(fetchStub.callCount, 1);
assert.strictEqual(fetchStub.firstCall.args[0], resourceMetadataUrl);
});
@@ -1583,6 +1602,8 @@ suite('OAuth', () => {
// Should succeed on root discovery fallback
const result = await fetchResourceMetadata(targetResource, resourceMetadataUrl, { fetch: fetchStub });
assert.deepStrictEqual(result.metadata, invalidMetadata);
assert.strictEqual(result.discoveryUrl, 'https://example.com/.well-known/oauth-protected-resource');
assert.ok(result.errors.length >= 1);
// Should have tried explicit URL, path-appended, then succeeded on root
assert.ok(fetchStub.callCount >= 2);
});
@@ -1639,7 +1660,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
// Should try OAuth discovery with path insertion: https://auth.example.com/.well-known/oauth-authorization-server/tenant
assert.strictEqual(fetchStub.firstCall.args[0], 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
@@ -1672,7 +1695,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/openid-configuration/tenant');
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(fetchStub.callCount, 2);
// First attempt: OAuth discovery
assert.strictEqual(fetchStub.firstCall.args[0], 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
@@ -1713,7 +1738,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/tenant/.well-known/openid-configuration');
assert.strictEqual(result.errors.length, 2);
assert.strictEqual(fetchStub.callCount, 3);
// First attempt: OAuth discovery
assert.strictEqual(fetchStub.firstCall.args[0], 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
@@ -1741,7 +1768,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
// For root URLs, no extra path is added
assert.strictEqual(fetchStub.firstCall.args[0], 'https://auth.example.com/.well-known/oauth-authorization-server');
@@ -1765,7 +1794,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server/tenant/');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
});
@@ -1787,8 +1818,9 @@ suite('OAuth', () => {
statusText: 'OK'
});
await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, additionalHeaders });
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, additionalHeaders });
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['X-Custom-Header'], 'custom-value');
assert.strictEqual(headers['Authorization'], 'Bearer token123');
@@ -1847,7 +1879,8 @@ suite('OAuth', () => {
// Should succeed on second attempt
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.errors.length, 1);
assert.strictEqual(fetchStub.callCount, 2);
});
@@ -1944,7 +1977,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer);
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(globalFetchStub.callCount, 1);
});
@@ -1966,7 +2001,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.errors.length, 1);
assert.ok(/Network error/.test(result.errors[0].message));
// Should have tried two endpoints
assert.strictEqual(fetchStub.callCount, 2);
});
@@ -2022,7 +2059,8 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.errors.length, 2);
// Should have tried all three endpoints
assert.strictEqual(fetchStub.callCount, 3);
});
@@ -2082,7 +2120,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/tenant/.well-known/openid-configuration');
assert.strictEqual(result.errors.length, 2);
assert.strictEqual(fetchStub.callCount, 3);
// Third attempt should correctly handle trailing slash (not double-slash)
assert.strictEqual(fetchStub.thirdCall.args[0], 'https://auth.example.com/tenant/.well-known/openid-configuration');
@@ -2104,7 +2144,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server/tenant/org/sub');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
// Should correctly insert well-known path with nested paths
assert.strictEqual(fetchStub.firstCall.args[0], 'https://auth.example.com/.well-known/oauth-authorization-server/tenant/org/sub');
@@ -2162,7 +2204,9 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, validMetadata);
assert.deepStrictEqual(result.metadata, validMetadata);
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
});
@@ -2182,7 +2226,10 @@ suite('OAuth', () => {
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub });
assert.deepStrictEqual(result, expectedMetadata);
assert.deepStrictEqual(result.metadata, expectedMetadata);
// Query parameters are not included in the discovery URL (only pathname is extracted)
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server/tenant');
assert.deepStrictEqual(result.errors, []);
assert.strictEqual(fetchStub.callCount, 1);
});
@@ -2200,8 +2247,9 @@ suite('OAuth', () => {
statusText: 'OK'
});
await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, additionalHeaders: {} });
const result = await fetchAuthorizationServerMetadata(authorizationServer, { fetch: fetchStub, additionalHeaders: {} });
assert.strictEqual(result.discoveryUrl, 'https://auth.example.com/.well-known/oauth-authorization-server');
const headers = fetchStub.firstCall.args[1].headers;
assert.strictEqual(headers['Accept'], 'application/json');
});
+3 -1
View File
@@ -1358,11 +1358,13 @@ export namespace CoreNavigationCommands {
if (args.revealCursor) {
// must ensure cursor is in new visible range
const desiredVisibleViewRange = viewModel.getCompletelyVisibleViewRangeAtScrollTop(desiredScrollTop);
const paddedRange = viewModel.getViewRangeWithCursorPadding(desiredVisibleViewRange);
viewModel.setCursorStates(
source,
CursorChangeReason.Explicit,
[
CursorMoveCommands.findPositionInViewportIfOutside(viewModel, viewModel.getPrimaryCursorState(), desiredVisibleViewRange, args.select)
CursorMoveCommands.findPositionInViewportIfOutside(viewModel, viewModel.getPrimaryCursorState(), paddedRange, args.select)
]
);
}
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, getActiveDocument } from '../../../../base/browser/dom.js';
import { $, getActiveDocument, getActiveWindow } from '../../../../base/browser/dom.js';
import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
import './media/decorationCssRuleExtractor.css';
@@ -81,4 +81,14 @@ export class DecorationCssRuleExtractor extends Disposable {
return rules;
}
/**
* Resolves a CSS variable to its computed value using the container element.
*/
resolveCssVariable(canvas: HTMLCanvasElement, variableName: string): string {
canvas.appendChild(this._container);
const result = getActiveWindow().getComputedStyle(this._container).getPropertyValue(variableName).trim();
canvas.removeChild(this._container);
return result;
}
}
@@ -18,6 +18,18 @@ export interface IDecorationStyleSet {
* A number between 0 and 1 representing the opacity of the text.
*/
opacity: number | undefined;
/**
* Whether the text should be rendered with a strikethrough.
*/
strikethrough: boolean | undefined;
/**
* The thickness of the strikethrough line in pixels (CSS pixels, not device pixels).
*/
strikethroughThickness: number | undefined;
/**
* A 32-bit number representing the strikethrough color.
*/
strikethroughColor: number | undefined;
}
export interface IDecorationStyleCacheEntry extends IDecorationStyleSet {
@@ -32,29 +44,49 @@ export class DecorationStyleCache {
private _nextId = 1;
private readonly _cacheById = new Map<number, IDecorationStyleCacheEntry>();
private readonly _cacheByStyle = new NKeyMap<IDecorationStyleCacheEntry, [number, number, string]>();
private readonly _cacheByStyle = new NKeyMap<IDecorationStyleCacheEntry, [number, number, string, number, string, number]>();
getOrCreateEntry(
color: number | undefined,
bold: boolean | undefined,
opacity: number | undefined
opacity: number | undefined,
strikethrough: boolean | undefined,
strikethroughThickness: number | undefined,
strikethroughColor: number | undefined
): number {
if (color === undefined && bold === undefined && opacity === undefined) {
if (color === undefined && bold === undefined && opacity === undefined && strikethrough === undefined && strikethroughThickness === undefined && strikethroughColor === undefined) {
return 0;
}
const result = this._cacheByStyle.get(color ?? 0, bold ? 1 : 0, opacity === undefined ? '' : opacity.toFixed(2));
const result = this._cacheByStyle.get(
color ?? 0,
bold ? 1 : 0,
opacity === undefined ? '' : opacity.toFixed(2),
strikethrough ? 1 : 0,
strikethroughThickness === undefined ? '' : strikethroughThickness.toFixed(2),
strikethroughColor ?? 0
);
if (result) {
return result.id;
}
const id = this._nextId++;
const entry = {
const entry: IDecorationStyleCacheEntry = {
id,
color,
bold,
opacity,
strikethrough,
strikethroughThickness,
strikethroughColor,
};
this._cacheById.set(id, entry);
this._cacheByStyle.set(entry, color ?? 0, bold ? 1 : 0, opacity === undefined ? '' : opacity.toFixed(2));
this._cacheByStyle.set(entry,
color ?? 0,
bold ? 1 : 0,
opacity === undefined ? '' : opacity.toFixed(2),
strikethrough ? 1 : 0,
strikethroughThickness === undefined ? '' : strikethroughThickness.toFixed(2),
strikethroughColor ?? 0
);
return id;
}
@@ -115,7 +115,7 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
// The sub-pixel x offset is the fractional part of the x pixel coordinate of the cell, this
// is used to improve the spacing between rendered characters.
const xSubPixelXOffset = (tokenMetadata & 0b1111) / 10;
const subPixelXOffset = (tokenMetadata & 0b1111) / 10;
const bgId = TokenMetadata.getBackground(tokenMetadata);
const bg = colorMap[bgId];
@@ -145,26 +145,54 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
fontSb.appendString(`${devicePixelFontSize}px ${this.fontFamily}`);
this._ctx.font = fontSb.build();
// TODO: Support FontStyle.Strikethrough and FontStyle.Underline text decorations, these
// need to be drawn manually to the canvas. See xterm.js for "dodging" the text for
// underlines.
// TODO: Support FontStyle.Underline text decorations, these need to be drawn manually to
// the canvas. See xterm.js for "dodging" the text for underlines.
const originX = devicePixelFontSize;
const originY = devicePixelFontSize;
// Apply text color
if (decorationStyleSet?.color !== undefined) {
this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;
} else {
this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];
}
this._ctx.textBaseline = 'top';
// Apply opacity
if (decorationStyleSet?.opacity !== undefined) {
this._ctx.globalAlpha = decorationStyleSet.opacity;
}
this._ctx.fillText(chars, originX + xSubPixelXOffset, originY);
// The glyph baseline is top, meaning it's drawn at the top-left of the
// cell. Add `TextMetrics.alphabeticBaseline` to the drawn position to
// get the alphabetic baseline.
this._ctx.textBaseline = 'top';
// Draw the text
this._ctx.fillText(chars, originX + subPixelXOffset, originY);
// Draw strikethrough
if (decorationStyleSet?.strikethrough) {
// TODO: This position could be refined further by checking
// TextMetrics of lowercase letters.
// Position strikethrough at approximately the vertical center of
// lowercase letters.
const strikethroughY = Math.round(originY - this._textMetrics.alphabeticBaseline * 0.65);
const lineWidth = decorationStyleSet?.strikethroughThickness !== undefined
? Math.round(decorationStyleSet.strikethroughThickness * this.devicePixelRatio)
: Math.max(1, Math.floor(devicePixelFontSize / 10));
// Apply strikethrough color if specified
if (decorationStyleSet?.strikethroughColor !== undefined) {
this._ctx.fillStyle = `#${decorationStyleSet.strikethroughColor.toString(16).padStart(8, '0')}`;
}
// Intentionally do not apply the sub pixel x offset to
// strikethrough to ensure successive glyphs form a contiguous line.
this._ctx.fillRect(originX, strikethroughY - Math.floor(lineWidth / 2), Math.ceil(this._textMetrics.width), lineWidth);
}
this._ctx.restore();
// Extract the image data and clear the background color
const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
if (this._antiAliasing === 'subpixel') {
const bgR = parseInt(bg.substring(1, 3), 16);
@@ -173,7 +201,10 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
this._clearColor(imageData, bgR, bgG, bgB);
this._ctx.putImageData(imageData, 0, 0);
}
// Find the bounding box
this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);
// const offset = {
// x: textMetrics.actualBoundingBoxLeft,
// y: textMetrics.actualBoundingBoxAscent
@@ -257,6 +257,9 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
let decorationStyleSetBold: boolean | undefined;
let decorationStyleSetColor: number | undefined;
let decorationStyleSetOpacity: number | undefined;
let decorationStyleSetStrikethrough: boolean | undefined;
let decorationStyleSetStrikethroughThickness: number | undefined;
let decorationStyleSetStrikethroughColor: number | undefined;
let lineData: ViewLineRenderingData;
let decoration: InlineDecoration;
@@ -375,6 +378,9 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
decorationStyleSetColor = undefined;
decorationStyleSetBold = undefined;
decorationStyleSetOpacity = undefined;
decorationStyleSetStrikethrough = undefined;
decorationStyleSetStrikethroughThickness = undefined;
decorationStyleSetStrikethroughColor = undefined;
// Apply supported inline decoration styles to the cell metadata
for (decoration of lineData.inlineDecorations) {
@@ -419,6 +425,36 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
decorationStyleSetOpacity = parsedValue;
break;
}
case 'text-decoration':
case 'text-decoration-line': {
if (value === 'line-through') {
decorationStyleSetStrikethrough = true;
}
break;
}
case 'text-decoration-thickness': {
const match = value.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
decorationStyleSetStrikethroughThickness = parseFloat(match[1]);
}
break;
}
case 'text-decoration-color': {
let colorValue = value;
const varMatch = value.match(/^var\((--[^,]+),\s*(?:initial|inherit)\)$/);
if (varMatch) {
colorValue = ViewGpuContext.decorationCssRuleExtractor.resolveCssVariable(this._viewGpuContext.canvas.domNode, varMatch[1]);
}
const parsedColor = Color.Format.CSS.parse(colorValue);
if (parsedColor) {
decorationStyleSetStrikethroughColor = parsedColor.toNumber32Bit();
}
break;
}
case 'text-decoration-style': {
// These are validated in canRender and use default behavior
break;
}
default: throw new BugIndicatingError('Unexpected inline decoration style');
}
}
@@ -443,7 +479,7 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
continue;
}
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity);
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity, decorationStyleSetStrikethrough, decorationStyleSetStrikethroughThickness, decorationStyleSetStrikethroughColor);
glyph = this._viewGpuContext.atlas.getGlyph(this.glyphRasterizer, chars, tokenMetadata, decorationStyleSetId, absoluteOffsetX);
absoluteOffsetY = Math.round(
@@ -209,6 +209,9 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
let decorationStyleSetBold: boolean | undefined;
let decorationStyleSetColor: number | undefined;
let decorationStyleSetOpacity: number | undefined;
let decorationStyleSetStrikethrough: boolean | undefined;
let decorationStyleSetStrikethroughThickness: number | undefined;
let decorationStyleSetStrikethroughColor: number | undefined;
let lineData: ViewLineRenderingData;
let decoration: InlineDecoration;
@@ -278,6 +281,9 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
decorationStyleSetColor = undefined;
decorationStyleSetBold = undefined;
decorationStyleSetOpacity = undefined;
decorationStyleSetStrikethrough = undefined;
decorationStyleSetStrikethroughThickness = undefined;
decorationStyleSetStrikethroughColor = undefined;
// Apply supported inline decoration styles to the cell metadata
for (decoration of lineData.inlineDecorations) {
@@ -322,6 +328,36 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
decorationStyleSetOpacity = parsedValue;
break;
}
case 'text-decoration':
case 'text-decoration-line': {
if (value === 'line-through') {
decorationStyleSetStrikethrough = true;
}
break;
}
case 'text-decoration-thickness': {
const match = value.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
decorationStyleSetStrikethroughThickness = parseFloat(match[1]);
}
break;
}
case 'text-decoration-color': {
let colorValue = value;
const varMatch = value.match(/^var\((--[^,]+),\s*(?:initial|inherit)\)$/);
if (varMatch) {
colorValue = ViewGpuContext.decorationCssRuleExtractor.resolveCssVariable(this._viewGpuContext.canvas.domNode, varMatch[1]);
}
const parsedColor = Color.Format.CSS.parse(colorValue);
if (parsedColor) {
decorationStyleSetStrikethroughColor = parsedColor.toNumber32Bit();
}
break;
}
case 'text-decoration-style': {
// These are validated in canRender and use default behavior
break;
}
default: throw new BugIndicatingError('Unexpected inline decoration style');
}
}
@@ -346,7 +382,7 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
continue;
}
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity);
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity, decorationStyleSetStrikethrough, decorationStyleSetStrikethroughThickness, decorationStyleSetStrikethroughColor);
glyph = this._viewGpuContext.atlas.getGlyph(this.glyphRasterizer, chars, tokenMetadata, decorationStyleSetId, absoluteOffsetX);
absoluteOffsetY = Math.round(
@@ -6,6 +6,7 @@
import * as nls from '../../../nls.js';
import { addDisposableListener, getActiveWindow } from '../../../base/browser/dom.js';
import { createFastDomNode, type FastDomNode } from '../../../base/browser/fastDomNode.js';
import { Color } from '../../../base/common/color.js';
import { BugIndicatingError } from '../../../base/common/errors.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js';
@@ -255,6 +256,11 @@ const gpuSupportedDecorationCssRules = [
'color',
'font-weight',
'opacity',
'text-decoration',
'text-decoration-color',
'text-decoration-line',
'text-decoration-style',
'text-decoration-thickness',
];
function supportsCssRule(rule: string, style: CSSStyleDeclaration) {
@@ -263,6 +269,31 @@ function supportsCssRule(rule: string, style: CSSStyleDeclaration) {
}
// Check for values that aren't supported
switch (rule) {
case 'text-decoration':
case 'text-decoration-line': {
const value = style.getPropertyValue(rule);
// Only line-through is supported currently
return value === 'line-through';
}
case 'text-decoration-color': {
const value = style.getPropertyValue(rule);
// Support var(--something, initial/inherit) which falls back to currentcolor
if (/^var\(--[^,]+,\s*(?:initial|inherit)\)$/.test(value)) {
return true;
}
// Support parsed color values
return Color.Format.CSS.parse(value) !== null;
}
case 'text-decoration-style': {
const value = style.getPropertyValue(rule);
// Only 'initial' (solid) is supported
return value === 'initial';
}
case 'text-decoration-thickness': {
const value = style.getPropertyValue(rule);
// Only pixel values and 'initial' are supported
return value === 'initial' || /^\d+(\.\d+)?px$/.test(value);
}
default: return true;
}
}
+1
View File
@@ -57,6 +57,7 @@ export interface IViewModel extends ICursorSimpleModel, ISimpleModel {
getMinimapLinesRenderingData(startLineNumber: number, endLineNumber: number, needed: boolean[]): MinimapLinesRenderingData;
getCompletelyVisibleViewRange(): Range;
getCompletelyVisibleViewRangeAtScrollTop(scrollTop: number): Range;
getViewRangeWithCursorPadding(viewRange: Range): Range;
getHiddenAreas(): Range[];
@@ -693,6 +693,32 @@ export class ViewModel extends Disposable implements IViewModel {
);
}
/**
* Applies `cursorSurroundingLines` and `stickyScroll` padding to the given view range.
*/
public getViewRangeWithCursorPadding(viewRange: Range): Range {
const options = this._configuration.options;
const cursorSurroundingLines = options.get(EditorOption.cursorSurroundingLines);
const stickyScroll = options.get(EditorOption.stickyScroll);
let { startLineNumber, endLineNumber } = viewRange;
const padding = Math.min(
Math.max(cursorSurroundingLines, stickyScroll.enabled ? stickyScroll.maxLineCount : 0),
Math.floor((endLineNumber - startLineNumber + 1) / 2));
startLineNumber += padding;
endLineNumber -= Math.max(0, padding - 1);
if (padding === 0 || startLineNumber > endLineNumber) {
return viewRange;
}
return new Range(
startLineNumber, this.getLineMinColumn(startLineNumber),
endLineNumber, this.getLineMaxColumn(endLineNumber)
);
}
public saveState(): IViewState {
const compatViewState = this.viewLayout.saveState();
@@ -260,6 +260,7 @@ export class FileService extends Disposable implements IFileService {
size: stat.size,
readonly: Boolean((stat.permissions ?? 0) & FilePermission.Readonly) || Boolean(provider.capabilities & FileSystemProviderCapabilities.Readonly),
locked: Boolean((stat.permissions ?? 0) & FilePermission.Locked),
executable: Boolean((stat.permissions ?? 0) & FilePermission.Executable),
etag: etag({ mtime: stat.mtime, size: stat.size }),
children: undefined
};
+14 -1
View File
@@ -473,7 +473,13 @@ export enum FilePermission {
* to edit the contents and ask the user upon saving to
* remove the lock.
*/
Locked = 2
Locked = 2,
/**
* File is executable. Relevant for Unix-like systems where
* the executable bit determines if a file can be run.
*/
Executable = 4
}
export interface IStat {
@@ -1247,6 +1253,12 @@ export interface IBaseFileStat {
* remove the lock.
*/
readonly locked?: boolean;
/**
* File is executable. Relevant for Unix-like systems where
* the executable bit determines if a file can be run.
*/
readonly executable?: boolean;
}
export interface IBaseFileStatWithMetadata extends Required<IBaseFileStat> { }
@@ -1287,6 +1299,7 @@ export interface IFileStatWithMetadata extends IFileStat, IBaseFileStatWithMetad
readonly size: number;
readonly readonly: boolean;
readonly locked: boolean;
readonly executable: boolean;
readonly children: IFileStatWithMetadata[] | undefined;
}
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Stats, promises } from 'fs';
import { Stats, constants, promises } from 'fs';
import { Barrier, retry } from '../../../base/common/async.js';
import { ResourceMap } from '../../../base/common/map.js';
import { VSBuffer } from '../../../base/common/buffer.js';
@@ -73,12 +73,24 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple
try {
const { stat, symbolicLink } = await SymlinkSupport.stat(this.toFilePath(resource)); // cannot use fs.stat() here to support links properly
let permissions: FilePermission | undefined = undefined;
if ((stat.mode & 0o200) === 0) {
permissions = FilePermission.Locked;
}
if (
stat.mode & constants.S_IXUSR ||
stat.mode & constants.S_IXGRP ||
stat.mode & constants.S_IXOTH
) {
permissions = (permissions ?? 0) | FilePermission.Executable;
}
return {
type: this.toType(stat, symbolicLink),
ctime: stat.birthtime.getTime(), // intentionally not using ctime here, we want the creation time
mtime: stat.mtime.getTime(),
size: stat.size,
permissions: (stat.mode & 0o200) === 0 ? FilePermission.Locked : undefined
permissions
};
} catch (error) {
throw this.toFileSystemProviderError(error);
@@ -496,6 +496,21 @@ flakySuite('Disk File Service', function () {
assert.ok(result.ctime > 0);
});
// The executable bit does not exist on Windows so use a condition not skip
if (!isWindows) {
test('stat - executable', async () => {
const nonExecutable = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/executable/non_executable');
let resolved = await service.stat(nonExecutable);
assert.strictEqual(resolved.isFile, true);
assert.strictEqual(resolved.executable, false);
const executable = FileAccess.asFileUri('vs/platform/files/test/node/fixtures/executable/executable');
resolved = await service.stat(executable);
assert.strictEqual(resolved.isFile, true);
assert.strictEqual(resolved.executable, true);
});
}
test('deleteFile (non recursive)', async () => {
return testDeleteFile(false, false);
});
@@ -260,6 +260,14 @@ registerQuickInputCommandAndKeybindingRule(
registerQuickPickCommandAndKeybindingRule(
{
id: 'quickInput.toggleCheckbox',
when: ContextKeyExpr.and(
inQuickInputContext,
ContextKeyExpr.or(
ContextKeyExpr.equals(quickInputTypeContextKeyValue, QuickInputType.QuickPick),
ContextKeyExpr.equals(quickInputTypeContextKeyValue, QuickInputType.QuickTree),
),
InputFocusedContext.negate()
),
primary: KeyCode.Space,
handler: accessor => {
const quickInputService = accessor.get(IQuickInputService);
@@ -167,7 +167,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess
// Delay resizes to avoid conpty not respecting very early resize calls
if (isWindows) {
if (useConpty && cols === 0 && rows === 0 && this.shellLaunchConfig.executable?.endsWith('Git\\bin\\bash.exe')) {
this._delayedResizer = new DelayedResizer();
this._delayedResizer = this._register(new DelayedResizer());
this._register(this._delayedResizer.onTrigger(dimensions => {
this._delayedResizer?.dispose();
this._delayedResizer = undefined;
@@ -177,11 +177,11 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess
}));
}
// WindowsShellHelper is used to fetch the process title and shell type
this.onProcessReady(e => {
this._register(this.onProcessReady(e => {
this._windowsShellHelper = this._register(new WindowsShellHelper(e.pid));
this._register(this._windowsShellHelper.onShellTypeChanged(e => this._onDidChangeProperty.fire({ type: ProcessPropertyType.ShellType, value: e })));
this._register(this._windowsShellHelper.onShellNameChanged(e => this._onDidChangeProperty.fire({ type: ProcessPropertyType.Title, value: e })));
});
}));
}
this._register(toDisposable(() => {
if (this._titleInterval) {
+13 -1
View File
@@ -16,6 +16,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../platform/contextkey
import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/dialogs.js';
import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js';
import { LogLevel } from '../../../platform/log/common/log.js';
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js';
import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js';
import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js';
import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js';
@@ -28,7 +29,7 @@ import { ExtensionHostKind, extensionHostKindToString } from '../../services/ext
import { IExtensionService } from '../../services/extensions/common/extensions.js';
import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js';
import { Proxied } from '../../services/extensions/common/proxyIdentifier.js';
import { ExtHostContext, ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthenticationOptions, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js';
import { ExtHostContext, ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthenticationOptions, IMcpAuthSetupTelemetry, MainContext, MainThreadMcpShape } from '../common/extHost.protocol.js';
@extHostNamedCustomer(MainContext.MainThreadMcp)
export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
@@ -55,6 +56,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
@IDynamicAuthenticationProviderStorageService private readonly _dynamicAuthenticationProviderStorageService: IDynamicAuthenticationProviderStorageService,
@IExtensionService private readonly _extensionService: IExtensionService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
) {
super();
this._register(_authenticationService.onDidChangeSessions(e => this._onDidChangeAuthSessions(e.providerId, e.label)));
@@ -355,6 +357,16 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
}
}
$logMcpAuthSetup(data: IMcpAuthSetupTelemetry): void {
type McpAuthSetupClassification = {
owner: 'TylerLeonhardt';
comment: 'Tracks how MCP OAuth authentication setup was discovered and configured';
resourceMetadataSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How resource metadata was discovered (header, wellKnown, or none)' };
serverMetadataSource: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How authorization server metadata was discovered (resourceMetadata, wellKnown, or default)' };
};
this._telemetryService.publicLog2<IMcpAuthSetupTelemetry, McpAuthSetupClassification>('mcp/authSetup', data);
}
private async loginPrompt(mcpLabel: string, providerLabel: string, recreatingSession: boolean): Promise<boolean> {
const message = recreatingSession
? nls.localize('confirmRelogin', "The MCP Server Definition '{0}' wants you to authenticate to {1}.", mcpLabel, providerLabel)
@@ -3156,6 +3156,23 @@ export interface IMcpAuthenticationOptions {
forceNewRegistration?: boolean;
}
export const enum McpAuthResourceMetadataSource {
Header = 'header',
WellKnown = 'wellKnown',
None = 'none',
}
export const enum McpAuthServerMetadataSource {
ResourceMetadata = 'resourceMetadata',
WellKnown = 'wellKnown',
Default = 'default',
}
export interface IMcpAuthSetupTelemetry {
resourceMetadataSource: McpAuthResourceMetadataSource;
serverMetadataSource: McpAuthServerMetadataSource;
}
export interface MainThreadMcpShape {
$onDidChangeState(id: number, state: McpConnectionState): void;
$onDidPublishLog(id: number, level: LogLevel, log: string): void;
@@ -3164,6 +3181,7 @@ export interface MainThreadMcpShape {
$deleteMcpCollection(collectionId: string): void;
$getTokenFromServerMetadata(id: number, authDetails: IMcpAuthenticationDetails, options?: IMcpAuthenticationOptions): Promise<string | undefined>;
$getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise<string | undefined>;
$logMcpAuthSetup(data: IMcpAuthSetupTelemetry): void;
}
export interface MainThreadDataChannelsShape extends IDisposable {
+37 -9
View File
@@ -21,7 +21,7 @@ import { StorageScope } from '../../../platform/storage/common/storage.js';
import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerStaticMetadata, McpServerStaticToolAvailability, McpServerTransportHTTP, McpServerTransportType, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js';
import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js';
import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js';
import { ExtHostMcpShape, IMcpAuthenticationDetails, IStartMcpOptions, MainContext, MainThreadMcpShape } from './extHost.protocol.js';
import { ExtHostMcpShape, IMcpAuthenticationDetails, IMcpAuthSetupTelemetry, IStartMcpOptions, MainContext, MainThreadMcpShape, McpAuthResourceMetadataSource, McpAuthServerMetadataSource } from './extHost.protocol.js';
import { IExtHostInitDataService } from './extHostInitDataService.js';
import { IExtHostRpcService } from './extHostRpcService.js';
import * as Convert from './extHostTypeConverters.js';
@@ -701,6 +701,7 @@ export class McpHTTPHandle extends Disposable {
fetch: (url, init) => this._fetch(url, init as MinimalRequestInit),
log: (level, message) => this._log(level, message)
});
this._proxy.$logMcpAuthSetup(this._authMetadata.telemetry);
await this._addAuthHeader(headers);
if (headers['Authorization']) {
// Update the headers in the init object
@@ -722,7 +723,7 @@ export class McpHTTPHandle extends Disposable {
// If we have an Authorization header and still get an auth error, we should retry with a new auth registration
if (headers['Authorization'] && isAuthStatusCode(res.status)) {
const errorText = await this._getErrText(res);
this._log(LogLevel.Debug, `Received ${res.status} status with Authorization header, retrying with new auth registration. Error details: ${errorText || 'no additional details'}`);
this._log(LogLevel.Info, `Received ${res.status} status with Authorization header, retrying with new auth registration. Error details: ${errorText || 'no additional details'}`);
await this._addAuthHeader(headers, true);
res = await doFetch();
}
@@ -839,6 +840,8 @@ export interface IAuthMetadata {
readonly serverMetadata: IAuthorizationServerMetadata;
readonly resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined;
readonly scopes: string[] | undefined;
/** Telemetry data about how auth metadata was discovered */
readonly telemetry: IMcpAuthSetupTelemetry;
/**
* Updates the scopes based on the WWW-Authenticate header in the response.
@@ -860,6 +863,7 @@ class AuthMetadata implements IAuthMetadata {
public readonly serverMetadata: IAuthorizationServerMetadata,
public readonly resourceMetadata: IAuthorizationProtectedResourceMetadata | undefined,
scopes: string[] | undefined,
public readonly telemetry: IMcpAuthSetupTelemetry,
private readonly _log: AuthMetadataLogger,
) {
this._scopes = scopes;
@@ -872,7 +876,7 @@ class AuthMetadata implements IAuthMetadata {
update(response: CommonResponse): boolean {
const scopesChallenge = this._parseScopesFromResponse(response);
if (!scopesMatch(scopesChallenge, this._scopes)) {
this._log(LogLevel.Debug, `Scopes changed from ${JSON.stringify(this._scopes)} to ${JSON.stringify(scopesChallenge)}, updating`);
this._log(LogLevel.Info, `Scopes changed from ${JSON.stringify(this._scopes)} to ${JSON.stringify(scopesChallenge)}, updating`);
this._scopes = scopesChallenge;
return true;
}
@@ -890,7 +894,7 @@ class AuthMetadata implements IAuthMetadata {
if (challenge.scheme === 'Bearer' && challenge.params['scope']) {
const scopes = challenge.params['scope'].split(AUTH_SCOPE_SEPARATOR).filter(s => s.trim().length);
if (scopes.length) {
this._log(LogLevel.Debug, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
this._log(LogLevel.Info, `Found scope challenge in WWW-Authenticate header: ${challenge.params['scope']}`);
return scopes;
}
}
@@ -932,6 +936,10 @@ export async function createAuthMetadata(
): Promise<AuthMetadata> {
const { launchHeaders, fetch, log } = options;
// Track discovery sources for telemetry
let resourceMetadataSource = McpAuthResourceMetadataSource.None;
let serverMetadataSource: McpAuthServerMetadataSource | undefined;
// Parse the WWW-Authenticate header for resource_metadata and scope challenges
const { resourceMetadataChallenge, scopesChallenge: scopesChallengeFromHeader } = parseWWWAuthenticateHeaderForChallenges(originalResponse, log);
@@ -941,7 +949,7 @@ export async function createAuthMetadata(
let scopesChallenge = scopesChallengeFromHeader;
try {
const { metadata, errors } = await fetchResourceMetadata(mcpUrl, resourceMetadataChallenge, {
const { metadata, discoveryUrl, errors } = await fetchResourceMetadata(mcpUrl, resourceMetadataChallenge, {
sameOriginHeaders: {
...Object.fromEntries(launchHeaders),
'MCP-Protocol-Version': MCP.LATEST_PROTOCOL_VERSION
@@ -951,10 +959,20 @@ export async function createAuthMetadata(
for (const err of errors) {
log(LogLevel.Warning, `Error fetching resource metadata: ${err}`);
}
log(LogLevel.Info, `Discovered resource metadata at ${discoveryUrl}`);
// Determine if resource metadata came from header or well-known
resourceMetadataSource = resourceMetadataChallenge ? McpAuthResourceMetadataSource.Header : McpAuthResourceMetadataSource.WellKnown;
// TODO:@TylerLeonhardt support multiple authorization servers
// Consider using one that has an auth provider first, over the dynamic flow
serverMetadataUrl = metadata.authorization_servers?.[0];
log(LogLevel.Debug, `Using auth server metadata url: ${serverMetadataUrl}`);
if (!serverMetadataUrl) {
log(LogLevel.Warning, `No authorization_servers found in resource metadata ${discoveryUrl} - Is this resource metadata configured correctly?`);
} else {
log(LogLevel.Info, `Using auth server metadata url: ${serverMetadataUrl}`);
serverMetadataSource = McpAuthServerMetadataSource.ResourceMetadata;
}
scopesChallenge ??= metadata.scopes_supported;
resource = metadata;
} catch (e) {
@@ -977,16 +995,25 @@ export async function createAuthMetadata(
try {
log(LogLevel.Debug, `Fetching auth server metadata for: ${serverMetadataUrl} ...`);
const serverMetadataResponse = await fetchAuthorizationServerMetadata(serverMetadataUrl, {
const { metadata, discoveryUrl, errors } = await fetchAuthorizationServerMetadata(serverMetadataUrl, {
additionalHeaders,
fetch: (url, init) => fetch(url, init as MinimalRequestInit)
});
log(LogLevel.Info, 'Populated auth metadata');
for (const err of errors) {
log(LogLevel.Warning, `Error fetching authorization server metadata: ${err}`);
}
log(LogLevel.Info, `Discovered authorization server metadata at ${discoveryUrl}`);
// If serverMetadataSource is not yet defined, it means we fell back to baseUrl
// and successfully fetched from well-known
serverMetadataSource ??= McpAuthServerMetadataSource.WellKnown;
return new AuthMetadata(
URI.parse(serverMetadataUrl),
serverMetadataResponse,
metadata,
resource,
scopesChallenge,
{ resourceMetadataSource, serverMetadataSource },
log
);
} catch (e) {
@@ -1001,6 +1028,7 @@ export async function createAuthMetadata(
defaultMetadata,
resource,
scopesChallenge,
{ resourceMetadataSource, serverMetadataSource: McpAuthServerMetadataSource.Default },
log
);
}
@@ -385,8 +385,8 @@ export class ExtHostNotebookController implements ExtHostNotebookShape {
size: stat.size,
readonly: Boolean((stat.permissions ?? 0) & files.FilePermission.Readonly) || !this._extHostFileSystem.value.isWritableFileSystem(uri.scheme),
locked: Boolean((stat.permissions ?? 0) & files.FilePermission.Locked),
etag: files.etag({ mtime: stat.mtime, size: stat.size }),
children: undefined
executable: Boolean((stat.permissions ?? 0) & files.FilePermission.Executable),
etag: files.etag({ mtime: stat.mtime, size: stat.size })
};
this.trace(`exit saveNotebook(versionId: ${versionId}, ${uri.toString()})`);
@@ -8,6 +8,7 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions
import { localize2 } from '../../../../../nls.js';
import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js';
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { IChatService } from '../../common/chatService/chatService.js';
import { IChatWidgetService } from '../chat.js';
@@ -15,6 +16,7 @@ import { IChatWidgetService } from '../chat.js';
export function registerChatDeveloperActions() {
registerAction2(LogChatInputHistoryAction);
registerAction2(LogChatIndexAction);
registerAction2(InspectChatModelAction);
}
class LogChatInputHistoryAction extends Action2 {
@@ -56,3 +58,57 @@ class LogChatIndexAction extends Action2 {
chatService.logChatIndex();
}
}
class InspectChatModelAction extends Action2 {
static readonly ID = 'workbench.action.chat.inspectChatModel';
constructor() {
super({
id: InspectChatModelAction.ID,
title: localize2('workbench.action.chat.inspectChatModel.label', "Inspect Chat Model"),
icon: Codicon.inspect,
category: Categories.Developer,
f1: true,
precondition: ChatContextKeys.enabled
});
}
override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> {
const chatWidgetService = accessor.get(IChatWidgetService);
const editorService = accessor.get(IEditorService);
const widget = chatWidgetService.lastFocusedWidget;
if (!widget?.viewModel) {
return;
}
const model = widget.viewModel.model;
const modelData = model.toJSON();
// Build markdown output with latest response at the top
let output = '# Chat Model Inspection\n\n';
// Show latest response first if it exists
const requests = modelData.requests;
if (requests && requests.length > 0) {
const latestRequest = requests[requests.length - 1];
if (latestRequest.response) {
output += '## Latest Response\n\n';
output += '```json\n' + JSON.stringify(latestRequest.response, null, 2) + '\n```\n\n';
}
}
// Show full model data
output += '## Full Chat Model\n\n';
output += '```json\n' + JSON.stringify(modelData, null, 2) + '\n```\n';
await editorService.openEditor({
resource: undefined,
contents: output,
languageId: 'markdown',
options: {
pinned: true
}
});
}
}
@@ -4,9 +4,11 @@
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../../base/common/codicons.js';
import { localize2 } from '../../../../../nls.js';
import { localize, localize2 } from '../../../../../nls.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { registerSingleton, InstantiationType } from '../../../../../platform/instantiation/common/extensions.js';
import { Registry } from '../../../../../platform/registry/common/platform.js';
import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../../../../../platform/quickinput/common/quickAccess.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js';
import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js';
@@ -14,6 +16,7 @@ import { LocalAgentsSessionsProvider } from './localAgentSessionsProvider.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js';
import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, ToggleChatViewSessionsAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction } from './agentSessionsActions.js';
import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js';
//#region Actions and Menus
@@ -145,6 +148,21 @@ MenuRegistry.appendMenuItem(MenuId.ChatViewSessionTitleToolbar, {
//#endregion
//#region Quick Access
Registry.as<IQuickAccessRegistry>(QuickAccessExtensions.Quickaccess).registerQuickAccessProvider({
ctor: AgentSessionsQuickAccessProvider,
prefix: AGENT_SESSIONS_QUICK_ACCESS_PREFIX,
contextKey: 'inAgentSessionsPicker',
placeholder: localize('agentSessionsQuickAccessPlaceholder', "Search agent sessions by name"),
helpEntries: [{
description: localize('agentSessionsQuickAccessHelp', "Show All Agent Sessions"),
commandId: 'workbench.action.chat.history',
}]
});
//#endregion
//#region Workbench Contributions
registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored);
@@ -22,26 +22,46 @@ interface ISessionPickItem extends IQuickPickItem {
readonly session: IAgentSession;
}
const archiveButton: IQuickInputButton = {
export const archiveButton: IQuickInputButton = {
iconClass: ThemeIcon.asClassName(Codicon.archive),
tooltip: localize('archiveSession', "Archive")
};
const unarchiveButton: IQuickInputButton = {
export const unarchiveButton: IQuickInputButton = {
iconClass: ThemeIcon.asClassName(Codicon.inbox),
tooltip: localize('unarchiveSession', "Unarchive")
};
const renameButton: IQuickInputButton = {
export const renameButton: IQuickInputButton = {
iconClass: ThemeIcon.asClassName(Codicon.edit),
tooltip: localize('renameSession', "Rename")
};
const deleteButton: IQuickInputButton = {
export const deleteButton: IQuickInputButton = {
iconClass: ThemeIcon.asClassName(Codicon.trash),
tooltip: localize('deleteSession', "Delete")
};
export function getSessionDescription(session: IAgentSession): string {
const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined;
const timeAgo = fromNow(session.timing.endTime || session.timing.startTime);
const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part);
return descriptionParts.join(' • ');
}
export function getSessionButtons(session: IAgentSession): IQuickInputButton[] {
const buttons: IQuickInputButton[] = [];
if (isLocalAgentSessionItem(session)) {
buttons.push(renameButton);
buttons.push(deleteButton);
}
buttons.push(session.isArchived() ? unarchiveButton : archiveButton);
return buttons;
}
export class AgentSessionsPicker {
private readonly sorter = new AgentSessionsSorter();
@@ -68,7 +88,7 @@ export class AgentSessionsPicker {
sideBySide: e.inBackground,
editorOptions: {
preserveFocus: e.inBackground,
pinned: false
pinned: e.inBackground
}
});
}
@@ -122,17 +142,8 @@ export class AgentSessionsPicker {
}
private toPickItem(session: IAgentSession): ISessionPickItem {
const descriptionText = typeof session.description === 'string' ? session.description : session.description ? renderAsPlaintext(session.description) : undefined;
const timeAgo = fromNow(session.timing.endTime || session.timing.startTime);
const descriptionParts = [descriptionText, session.providerLabel, timeAgo].filter(part => !!part);
const description = descriptionParts.join(' • ');
const buttons: IQuickInputButton[] = [];
if (isLocalAgentSessionItem(session)) {
buttons.push(renameButton);
buttons.push(deleteButton);
}
buttons.push(session.isArchived() ? unarchiveButton : archiveButton);
const description = getSessionDescription(session);
const buttons = getSessionButtons(session);
return {
id: session.resource.toString(),
@@ -0,0 +1,101 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IKeyMods, IQuickPickDidAcceptEvent, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
import { PickerQuickAccessProvider, IPickerQuickAccessItem, TriggerAction } from '../../../../../platform/quickinput/browser/pickerQuickAccess.js';
import { localize } from '../../../../../nls.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IMatch, matchesFuzzy } from '../../../../../base/common/filters.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { IAgentSessionsService } from './agentSessionsService.js';
import { AgentSessionsSorter, groupAgentSessions } from './agentSessionsViewer.js';
import { IAgentSession } from './agentSessionsModel.js';
import { openSession } from './agentSessionsOpener.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js';
import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, unarchiveButton } from './agentSessionsPicker.js';
export const AGENT_SESSIONS_QUICK_ACCESS_PREFIX = 'agent ';
export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider<IPickerQuickAccessItem> {
private readonly sorter = new AgentSessionsSorter();
constructor(
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ICommandService private readonly commandService: ICommandService,
) {
super(AGENT_SESSIONS_QUICK_ACCESS_PREFIX, {
canAcceptInBackground: true,
noResultsPick: {
label: localize('noAgentSessionResults', "No matching agent sessions")
}
});
}
protected async _getPicks(filter: string): Promise<(IQuickPickSeparator | IPickerQuickAccessItem)[]> {
const picks: Array<IPickerQuickAccessItem | IQuickPickSeparator> = [];
const sessions = this.agentSessionsService.model.sessions.sort(this.sorter.compare.bind(this.sorter));
const groupedSessions = groupAgentSessions(sessions);
for (const group of groupedSessions.values()) {
if (group.sessions.length > 0) {
picks.push({ type: 'separator', label: group.label });
for (const session of group.sessions) {
const highlights = matchesFuzzy(filter, session.label, true);
if (highlights) {
picks.push(this.toPickItem(session, highlights));
}
}
}
}
return picks;
}
private toPickItem(session: IAgentSession, highlights: IMatch[]): IPickerQuickAccessItem {
const description = getSessionDescription(session);
const buttons = getSessionButtons(session);
return {
label: session.label,
description,
highlights: { label: highlights },
iconClass: ThemeIcon.asClassName(session.icon),
buttons,
trigger: async (buttonIndex) => {
const button = buttons[buttonIndex];
switch (button) {
case renameButton:
await this.commandService.executeCommand(AGENT_SESSION_RENAME_ACTION_ID, session);
return TriggerAction.REFRESH_PICKER;
case deleteButton:
await this.commandService.executeCommand(AGENT_SESSION_DELETE_ACTION_ID, session);
return TriggerAction.REFRESH_PICKER;
case archiveButton:
case unarchiveButton: {
const newArchivedState = !session.isArchived();
session.setArchived(newArchivedState);
return TriggerAction.REFRESH_PICKER;
}
default:
return TriggerAction.NO_ACTION;
}
},
accept: (keyMods: IKeyMods, event: IQuickPickDidAcceptEvent) => {
this.instantiationService.invokeFunction(openSession, session, {
sideBySide: event.inBackground,
editorOptions: {
preserveFocus: event.inBackground,
pinned: event.inBackground
}
});
}
};
}
}
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, clearNode } from '../../../../../../base/browser/dom.js';
import { $, clearNode, hide } from '../../../../../../base/browser/dom.js';
import { IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js';
import { IChatContentPartRenderContext, IChatContentPart } from './chatContentParts.js';
import { IChatRendererContent } from '../../../common/model/chatViewModel.js';
@@ -94,8 +94,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
private toolInvocationCount: number = 0;
private streamingCompleted: boolean = false;
private isActive: boolean = true;
private currentToolCallLabel: string | undefined;
private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = [];
private singleToolItemInfo: { element: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null } | undefined;
constructor(
content: IChatThinkingPart,
@@ -356,11 +356,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
return;
}
// case where we only have one dropdown in the thinking container and no thinking parts
if (this.toolInvocationCount === 1 && this.extractedTitles.length === 1 && this.currentToolCallLabel && this.currentThinkingValue.trim() === '') {
const title = this.currentToolCallLabel;
this.currentTitle = title;
this.setTitleWithWidgets(new MarkdownString(title), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer);
// case where we only have one tool in the thinking container and no thinking parts, we want to move it back to its original position
if (this.toolInvocationCount === 1 && this.currentThinkingValue.trim() === '' && this.singleToolItemInfo) {
this.restoreSingleToolToOriginalPosition();
return;
}
@@ -455,6 +453,23 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
this.setFallbackTitle();
}
private restoreSingleToolToOriginalPosition(): void {
if (!this.singleToolItemInfo) {
return;
}
const { element, originalParent, originalNextSibling } = this.singleToolItemInfo;
if (originalNextSibling && originalNextSibling.parentNode === originalParent) {
originalParent.insertBefore(element, originalNextSibling);
} else {
originalParent.appendChild(element);
}
hide(this.domNode);
this.singleToolItemInfo = undefined;
}
private setFallbackTitle(): void {
const finalLabel = this.toolInvocationCount > 0
? localize('chat.thinking.finished.withTools', 'Finished thinking and invoked {0} tool{1}', this.toolInvocationCount, this.toolInvocationCount === 1 ? '' : 's')
@@ -472,7 +487,18 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
this.updateDropdownClickability();
}
public appendItem(content: HTMLElement, toolInvocationId?: string, toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized): void {
public appendItem(content: HTMLElement, toolInvocationId?: string, toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized, originalParent?: HTMLElement): void {
// save the first tool item info for potential restoration later
if (this.toolInvocationCount === 0 && originalParent) {
this.singleToolItemInfo = {
element: content,
originalParent,
originalNextSibling: this.domNode
};
} else {
this.singleToolItemInfo = undefined;
}
const itemWrapper = $('.chat-thinking-tool-wrapper');
const icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId) : Codicon.tools;
const iconElement = createThinkingIcon(icon);
@@ -490,11 +516,6 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen
} else {
toolCallLabel = `Invoked \`${toolInvocationId}\``;
}
if (toolInvocation?.pastTenseMessage) {
this.currentToolCallLabel = typeof toolInvocation.pastTenseMessage === 'string' ? toolInvocation.pastTenseMessage : toolInvocation.pastTenseMessage.value;
}
if (toolInvocation) {
this.toolInvocations.push(toolInvocation);
}
@@ -55,6 +55,7 @@ export interface ITerminalNewAutoApproveRule {
approve: boolean;
matchCommandLine?: boolean;
};
scope: 'session' | 'workspace' | 'user';
}
export type TerminalNewAutoApproveButtonData = (
@@ -231,13 +232,13 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
const optedIn = await this._showAutoApproveWarning();
if (optedIn) {
this.storageService.store(TerminalToolConfirmationStorageKeys.TerminalAutoApproveWarningAccepted, true, StorageScope.APPLICATION, StorageTarget.USER);
// This is good to auto approve immediately
if (!terminalCustomActions) {
// If this command would have been auto-approved, approve immediately
if (terminalData.autoApproveInfo) {
toolConfirmKind = ToolConfirmKind.UserAction;
}
// If this would not have been auto approved, enable the options and
// do not complete
else {
else if (terminalCustomActions) {
for (const action of terminalCustomActions) {
if (!(action instanceof Separator)) {
action.disabled = false;
@@ -258,28 +259,65 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
}
case 'newRule': {
const newRules = asArray(data.rule);
const inspect = this.configurationService.inspect(TerminalContribSettingId.AutoApprove);
const oldValue = (inspect.user?.value as Record<string, unknown> | undefined) ?? {};
let newValue: Record<string, unknown>;
if (isObject(oldValue)) {
newValue = { ...oldValue };
for (const newRule of newRules) {
newValue[newRule.key] = newRule.value;
}
} else {
this.preferencesService.openSettings({
jsonEditor: true,
target: ConfigurationTarget.USER,
revealSetting: {
key: TerminalContribSettingId.AutoApprove
},
});
throw new ErrorNoTelemetry(`Cannot add new rule, existing setting is unexpected format`);
// Group rules by scope
const sessionRules = newRules.filter(r => r.scope === 'session');
const workspaceRules = newRules.filter(r => r.scope === 'workspace');
const userRules = newRules.filter(r => r.scope === 'user');
// Handle session-scoped rules (temporary, in-memory only)
const chatSessionId = this.context.element.sessionId;
for (const rule of sessionRules) {
this.terminalChatService.addSessionAutoApproveRule(chatSessionId, rule.key, rule.value);
}
await this.configurationService.updateValue(TerminalContribSettingId.AutoApprove, newValue, ConfigurationTarget.USER);
function formatRuleLinks(newRules: ITerminalNewAutoApproveRule[]): string {
return newRules.map(e => {
const settingsUri = createCommandUri(TerminalContribCommandId.OpenTerminalSettingsLink, ConfigurationTarget.USER);
// Handle workspace-scoped rules
if (workspaceRules.length > 0) {
const inspect = this.configurationService.inspect(TerminalContribSettingId.AutoApprove);
const oldValue = (inspect.workspaceValue as Record<string, unknown> | undefined) ?? {};
if (isObject(oldValue)) {
const newValue: Record<string, unknown> = { ...oldValue };
for (const rule of workspaceRules) {
newValue[rule.key] = rule.value;
}
await this.configurationService.updateValue(TerminalContribSettingId.AutoApprove, newValue, ConfigurationTarget.WORKSPACE);
} else {
this.preferencesService.openSettings({
jsonEditor: true,
target: ConfigurationTarget.WORKSPACE,
revealSetting: { key: TerminalContribSettingId.AutoApprove },
});
throw new ErrorNoTelemetry(`Cannot add new rule, existing workspace setting is unexpected format`);
}
}
// Handle user-scoped rules
if (userRules.length > 0) {
const inspect = this.configurationService.inspect(TerminalContribSettingId.AutoApprove);
const oldValue = (inspect.userValue as Record<string, unknown> | undefined) ?? {};
if (isObject(oldValue)) {
const newValue: Record<string, unknown> = { ...oldValue };
for (const rule of userRules) {
newValue[rule.key] = rule.value;
}
await this.configurationService.updateValue(TerminalContribSettingId.AutoApprove, newValue, ConfigurationTarget.USER);
} else {
this.preferencesService.openSettings({
jsonEditor: true,
target: ConfigurationTarget.USER,
revealSetting: { key: TerminalContribSettingId.AutoApprove },
});
throw new ErrorNoTelemetry(`Cannot add new rule, existing setting is unexpected format`);
}
}
function formatRuleLinks(rules: ITerminalNewAutoApproveRule[], scope: 'session' | 'workspace' | 'user'): string {
return rules.map(e => {
if (scope === 'session') {
return `\`${e.key}\``;
}
const target = scope === 'workspace' ? ConfigurationTarget.WORKSPACE : ConfigurationTarget.USER;
const settingsUri = createCommandUri(TerminalContribCommandId.OpenTerminalSettingsLink, target);
return `[\`${e.key}\`](${settingsUri.toString()} "${localize('ruleTooltip', 'View rule in settings')}")`;
}).join(', ');
}
@@ -288,10 +326,24 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS
enabledCommands: [TerminalContribCommandId.OpenTerminalSettingsLink]
}
};
if (newRules.length === 1) {
terminalData.autoApproveInfo = new MarkdownString(localize('newRule', 'Auto approve rule {0} added', formatRuleLinks(newRules)), mdTrustSettings);
} else if (newRules.length > 1) {
terminalData.autoApproveInfo = new MarkdownString(localize('newRule.plural', 'Auto approve rules {0} added', formatRuleLinks(newRules)), mdTrustSettings);
const parts: string[] = [];
if (sessionRules.length > 0) {
parts.push(sessionRules.length === 1
? localize('newRule.session', 'Session rule {0} added', formatRuleLinks(sessionRules, 'session'))
: localize('newRule.session.plural', 'Session rules {0} added', formatRuleLinks(sessionRules, 'session')));
}
if (workspaceRules.length > 0) {
parts.push(workspaceRules.length === 1
? localize('newRule.workspace', 'Workspace rule {0} added', formatRuleLinks(workspaceRules, 'workspace'))
: localize('newRule.workspace.plural', 'Workspace rules {0} added', formatRuleLinks(workspaceRules, 'workspace')));
}
if (userRules.length > 0) {
parts.push(userRules.length === 1
? localize('newRule.user', 'User rule {0} added', formatRuleLinks(userRules, 'user'))
: localize('newRule.user.plural', 'User rules {0} added', formatRuleLinks(userRules, 'user')));
}
if (parts.length > 0) {
terminalData.autoApproveInfo = new MarkdownString(parts.join(', '), mdTrustSettings);
}
toolConfirmKind = ToolConfirmKind.UserAction;
break;
@@ -1555,7 +1555,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}, context, templateData);
if (thinkingPart instanceof ChatThinkingContentPart) {
thinkingPart.appendItem(part?.domNode, toolInvocation.toolId, toolInvocation);
thinkingPart.appendItem(part?.domNode, toolInvocation.toolId, toolInvocation, templateData.value);
thinkingPart.addDisposable(part);
thinkingPart.addDisposable(thinkingPart.onDidChangeHeight(() => {
this.updateItemHeight(templateData);
@@ -1567,7 +1567,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
if (this.shouldPinPart(toolInvocation, context.element)) {
if (lastThinking && part?.domNode && toolInvocation.presentation !== 'hidden') {
lastThinking.appendItem(part?.domNode, toolInvocation.toolId, toolInvocation);
lastThinking.appendItem(part?.domNode, toolInvocation.toolId, toolInvocation, templateData.value);
lastThinking.addDisposable(part);
}
} else {
@@ -305,6 +305,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.logService.debug('ChatWidget#setViewModel: no viewModel');
}
this.currentRequest = undefined;
this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource });
}
@@ -1642,7 +1643,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.inputContainer = dom.$('.chat-edit-input-container');
rowContainer.appendChild(this.inputContainer);
this.createInput(this.inputContainer);
this.input.setChatMode(this.inputPart.currentModeKind);
this.input.setChatMode(this.inputPart.currentModeObs.get().id);
} else {
this.inputPart.element.classList.add('editing');
}
@@ -1708,7 +1709,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
const isInput = this.configurationService.getValue<string>('chat.editRequests') === 'input';
if (!isInput) {
this.inputPart.setChatMode(this.input.currentModeKind);
this.inputPart.setChatMode(this.input.currentModeObs.get().id);
const currentModel = this.input.selectedLanguageModel;
if (currentModel) {
this.inputPart.switchModel(currentModel.metadata);
@@ -231,6 +231,10 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService
this._widgets.push(newWidget);
this._onDidAddWidget.fire(newWidget);
if (!this._lastFocusedWidget) {
this.setLastFocusedWidget(newWidget);
}
return combinedDisposable(
newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)),
newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => {
@@ -245,7 +249,12 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService
}
});
}),
toDisposable(() => this._widgets.splice(this._widgets.indexOf(newWidget), 1))
toDisposable(() => {
this._widgets.splice(this._widgets.indexOf(newWidget), 1);
if (this._lastFocusedWidget === newWidget) {
this.setLastFocusedWidget(undefined);
}
})
);
}
}
@@ -38,7 +38,7 @@ import { INotificationService } from '../../../../../../../platform/notification
import { Registry } from '../../../../../../../platform/registry/common/platform.js';
import { IWorkspaceContextService } from '../../../../../../../platform/workspace/common/workspace.js';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../../../../common/contributions.js';
import { EditorsOrder } from '../../../../../../common/editor.js';
import { EditorsOrder, isDiffEditorInput } from '../../../../../../common/editor.js';
import { IEditorService } from '../../../../../../services/editor/common/editorService.js';
import { IHistoryService } from '../../../../../../services/history/common/history.js';
import { LifecyclePhase } from '../../../../../../services/lifecycle/common/lifecycle.js';
@@ -974,21 +974,22 @@ class BuiltinDynamicCompletions extends Disposable {
// HISTORY
// always take the last N items
for (const [i, item] of this.historyService.getHistory().entries()) {
if (!item.resource || seen.has(item.resource) || !this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, item.resource!.scheme))) {
const resource = isDiffEditorInput(item) ? item.modified.resource : item.resource;
if (!resource || seen.has(resource) || !this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, resource.scheme))) {
// ignore editors without a resource
continue;
}
if (pattern) {
// use pattern if available
const basename = this.labelService.getUriBasenameLabel(item.resource).toLowerCase();
const basename = this.labelService.getUriBasenameLabel(resource).toLowerCase();
if (!isPatternInWord(pattern, 0, pattern.length, basename, 0, basename.length)) {
continue;
}
}
seen.add(item.resource);
const newLen = result.suggestions.push(makeCompletionItem(item.resource, FileKind.FILE, i === 0 ? localize('activeFile', 'Active file') : undefined, i === 0));
seen.add(resource);
const newLen = result.suggestions.push(makeCompletionItem(resource, FileKind.FILE, i === 0 ? localize('activeFile', 'Active file') : undefined, i === 0));
if (newLen - len >= 5) {
break;
}
@@ -5,7 +5,7 @@
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { Disposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { isEqual } from '../../../../../../base/common/resources.js';
import { truncate } from '../../../../../../base/common/strings.js';
@@ -17,27 +17,21 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/
import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js';
import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../../common/editor.js';
import { EditorInput, IEditorCloseHandler } from '../../../../../common/editor/editorInput.js';
import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js';
import { IChatModel } from '../../../common/model/chatModel.js';
import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js';
import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js';
import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js';
import { ChatAgentLocation, ChatEditorTitleMaxLength } from '../../../common/constants.js';
import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js';
import { IChatModel } from '../../../common/model/chatModel.js';
import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js';
import { IClearEditingSessionConfirmationOptions } from '../../actions/chatActions.js';
import type { IChatEditorOptions } from './chatEditor.js';
const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.chatSparkle, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.'));
export class ChatEditorInput extends EditorInput implements IEditorCloseHandler {
/** Maps input name strings to sets of active editor counts */
static readonly countsInUseMap = new Map<string, Set<number>>();
static readonly TypeID: string = 'workbench.input.chatSession';
static readonly EditorID: string = 'workbench.editor.chatSession';
private readonly inputCount: number;
private readonly inputName: string;
private _sessionResource: URI | undefined;
/**
@@ -47,7 +41,6 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
*/
public get sessionResource(): URI | undefined { return this._sessionResource; }
private hasCustomTitle: boolean = false;
private didTransferOutEditingSession = false;
private cachedIcon: ThemeIcon | URI | undefined;
@@ -61,15 +54,6 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
return ChatEditorUri.getNewEditorUri();
}
private static getNextCount(inputName: string): number {
let count = 0;
while (ChatEditorInput.countsInUseMap.get(inputName)?.has(count)) {
count++;
}
return count;
}
constructor(
readonly resource: URI,
readonly options: IChatEditorOptions,
@@ -93,36 +77,6 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
} else {
this._sessionResource = resource;
}
// Check if we already have a custom title for this session
const hasExistingCustomTitle = this._sessionResource && (
this.chatService.getSessionTitle(this._sessionResource)?.trim()
);
this.hasCustomTitle = Boolean(hasExistingCustomTitle);
// Input counts are unique to the displayed fallback title
this.inputName = options.title?.fallback ?? '';
if (!ChatEditorInput.countsInUseMap.has(this.inputName)) {
ChatEditorInput.countsInUseMap.set(this.inputName, new Set());
}
// Only allocate a count if we don't already have a custom title
if (!this.hasCustomTitle) {
this.inputCount = ChatEditorInput.getNextCount(this.inputName);
ChatEditorInput.countsInUseMap.get(this.inputName)?.add(this.inputCount);
this._register(toDisposable(() => {
// Only remove if we haven't already removed it due to custom title
if (!this.hasCustomTitle) {
ChatEditorInput.countsInUseMap.get(this.inputName)?.delete(this.inputCount);
if (ChatEditorInput.countsInUseMap.get(this.inputName)?.size === 0) {
ChatEditorInput.countsInUseMap.delete(this.inputName);
}
}
}));
} else {
this.inputCount = 0; // Not used when we have a custom title
}
}
override closeHandler = this;
@@ -195,9 +149,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
}
// Fall back to default naming pattern
const inputCountSuffix = (this.inputCount > 0 ? ` ${this.inputCount + 1}` : '');
const defaultName = this.options.title?.fallback ?? nls.localize('chatEditorName', "Chat");
return defaultName + inputCountSuffix;
return this.options.title?.fallback ?? nls.localize('chatEditorName', "Chat");
}
override getTitle(verbosity?: Verbosity): string {
@@ -277,14 +229,6 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler
this._sessionResource = this.model.sessionResource;
this._register(this.model.onDidChange((e) => {
// When a custom title is set, we no longer need the numeric count
if (e && e.kind === 'setCustomTitle' && !this.hasCustomTitle) {
this.hasCustomTitle = true;
ChatEditorInput.countsInUseMap.get(this.inputName)?.delete(this.inputCount);
if (ChatEditorInput.countsInUseMap.get(this.inputName)?.size === 0) {
ChatEditorInput.countsInUseMap.delete(this.inputName);
}
}
// Invalidate icon cache when label changes
this.cachedIcon = undefined;
this._onDidChangeLabel.fire();
@@ -56,7 +56,8 @@ class ExtendedTestFileService extends TestFileService {
mtime: 0,
ctime: 0,
readonly: false,
locked: false
locked: false,
executable: false
};
}
@@ -3070,6 +3070,8 @@ class SCMTreeDataSource extends Disposable implements IAsyncDataSource<ISCMViewS
return result;
} else if (isSCMInput(element)) {
return element.repository;
} else if (isSCMActionButton(element)) {
return element.repository;
} else if (isSCMResourceGroup(element)) {
const repository = this.scmViewService.visibleRepositories.find(r => r.provider === element.provider);
if (!repository) {
@@ -3077,6 +3079,8 @@ class SCMTreeDataSource extends Disposable implements IAsyncDataSource<ISCMViewS
}
return repository;
} else if (isSCMRepository(element)) {
return this.scmViewService;
} else {
throw new Error('Unexpected call to getParent');
}
@@ -59,7 +59,7 @@ abstract class DetachedTerminalMirror extends Disposable {
container.classList.add('chat-terminal-output-terminal');
const needsAttach = this._attachedContainer !== container || container.firstChild === null;
if (needsAttach) {
terminal.attachToElement(container);
terminal.attachToElement(container, { enableGpu: false });
this._attachedContainer = container;
}
return terminal;
@@ -217,6 +217,21 @@ export interface ITerminalChatService {
* @returns True if the session has auto approval enabled
*/
hasChatSessionAutoApproval(chatSessionId: string): boolean;
/**
* Add a session-scoped auto-approve rule.
* @param chatSessionId The chat session ID to associate the rule with
* @param key The rule key (command or regex pattern)
* @param value The rule value (approval boolean or object with approve and matchCommandLine)
*/
addSessionAutoApproveRule(chatSessionId: string, key: string, value: boolean | { approve: boolean; matchCommandLine?: boolean }): void;
/**
* Get all session-scoped auto-approve rules for a specific chat session.
* @param chatSessionId The chat session ID to get rules for
* @returns A record of all session-scoped auto-approve rules for the session
*/
getSessionAutoApproveRules(chatSessionId: string): Readonly<Record<string, boolean | { approve: boolean; matchCommandLine?: boolean }>>;
}
/**
@@ -592,13 +592,12 @@ export class TerminalTabbedView extends Disposable {
// be focused. So wait for connection to finish, then focus.
const previousActiveElement = this._tabListElement.ownerDocument.activeElement;
if (previousActiveElement) {
// TODO: Improve lifecycle management this event should be disposed after first fire
this._register(this._terminalService.onDidChangeConnectionState(() => {
const listener = this._register(Event.once(this._terminalService.onDidChangeConnectionState)(() => {
// Only focus the terminal if the activeElement has not changed since focus() was called
// TODO: Hack
if (dom.isActiveElement(previousActiveElement)) {
this._focus();
}
this._store.delete(listener);
}));
}
}
@@ -337,13 +337,12 @@ export class TerminalViewPane extends ViewPane {
// be focused. So wait for connection to finish, then focus.
const previousActiveElement = this.element.ownerDocument.activeElement;
if (previousActiveElement) {
// TODO: Improve lifecycle management this event should be disposed after first fire
this._register(this._terminalService.onDidChangeConnectionState(() => {
const listener = this._register(Event.once(this._terminalService.onDidChangeConnectionState)(() => {
// Only focus the terminal if the activeElement has not changed since focus() was called
// TODO: Hack
if (previousActiveElement && dom.isActiveElement(previousActiveElement)) {
this._terminalGroupService.showPanel(true);
}
this._store.delete(listener);
}));
}
}
@@ -803,6 +803,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach
if (!this.raw.element || this._webglAddon && this._webglAddonCustomGlyphs === this._terminalConfigurationService.config.customGlyphs) {
return;
}
// Dispose of existing addon before creating a new one to avoid leaking WebGL contexts
this._disposeOfWebglRenderer();
this._webglAddonCustomGlyphs = this._terminalConfigurationService.config.customGlyphs;
const Addon = await this._xtermAddonLoader.importAddon('webgl');
@@ -892,6 +896,9 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach
}
private _disposeOfWebglRenderer(): void {
if (!this._webglAddon) {
return;
}
try {
this._webglAddon?.dispose();
} catch {
@@ -1001,6 +1008,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach
override dispose(): void {
this._anyTerminalFocusContextKey.reset();
this._anyFocusedTerminalHasSelection.reset();
this._disposeOfWebglRenderer();
this._onDidDispose.fire();
super.dispose();
}
@@ -62,6 +62,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
builtin return
fi
# Prevent AI-executed commands from polluting shell history
if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then
export HISTCONTROL="ignorespace"
builtin unset VSCODE_PREVENT_SHELL_HISTORY
fi
# Apply EnvironmentVariableCollections if needed
if [ -n "${VSCODE_ENV_REPLACE:-}" ]; then
IFS=':' read -ra ADDR <<< "$VSCODE_ENV_REPLACE"
@@ -98,6 +98,12 @@ if [ -z "$VSCODE_SHELL_INTEGRATION" ]; then
builtin return
fi
# Prevent AI-executed commands from polluting shell history
if [ "${VSCODE_PREVENT_SHELL_HISTORY:-}" = "1" ]; then
builtin setopt HIST_IGNORE_SPACE
builtin unset VSCODE_PREVENT_SHELL_HISTORY
fi
# The property (P) and command (E) codes embed values which require escaping.
# Backslashes are doubled. Non-alphanumeric characters are converted to escaped hex.
__vsc_escape_value() {
@@ -23,6 +23,13 @@ or exit
set --global VSCODE_SHELL_INTEGRATION 1
set --global __vscode_shell_env_reporting $VSCODE_SHELL_ENV_REPORTING
set -e VSCODE_SHELL_ENV_REPORTING
# Prevent AI-executed commands from polluting shell history
if test "$VSCODE_PREVENT_SHELL_HISTORY" = "1"
set -g fish_private_mode 1
set -e VSCODE_PREVENT_SHELL_HISTORY
end
set -g envVarsToReport
if test -n "$__vscode_shell_env_reporting"
set envVarsToReport (string split "," "$__vscode_shell_env_reporting")
@@ -259,4 +259,13 @@ function Set-MappedKeyHandlers {
if ($Global:__VSCodeState.HasPSReadLine) {
Set-MappedKeyHandlers
# Prevent AI-executed commands from polluting shell history
if ($env:VSCODE_PREVENT_SHELL_HISTORY -eq "1") {
Set-PSReadLineOption -AddToHistoryHandler {
param([string]$line)
return $false
}
$env:VSCODE_PREVENT_SHELL_HISTORY = $null
}
}
@@ -572,8 +572,10 @@ const terminalConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
[TerminalSettingId.CustomGlyphs]: {
markdownDescription: localize('terminal.integrated.customGlyphs', "Whether to draw custom glyphs instead of using the font for the following unicode ranges:\n\n{0}\n\nThis will typically result in better rendering with continuous lines, even when line height and letter spacing is used. This feature only works when {1} is enabled.", [
'- Box Drawing (U+2500-U+257F)',
'- Block Elements (U+2580-U+259F)',
'- Braille Patterns (U+2800-U+28FF)',
'- Powerline Symbols (U+E0A0-U+E0D4, Private Use Area)',
'- Progress Indicators (U+EE00-U+EE0B, Private Use Area)',
'- Git Branch Symbols (U+F5D0-U+F60D, Private Use Area)',
'- Symbols for Legacy Computing (U+1FB00-U+1FBFF)'
].join('\n'), `\`#${TerminalSettingId.GpuAcceleration}#\``),
@@ -31,6 +31,7 @@
.monaco-workbench .terminal-inline-chat .interactive-session {
margin: initial;
max-width: unset;
}
.monaco-workbench .terminal-inline-chat.hide {
@@ -53,6 +53,13 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
*/
private readonly _sessionAutoApprovalEnabled = new Set<string>();
/**
* Tracks session-scoped auto-approve rules per chat session. These are temporary rules that
* last only for the duration of the chat session (not persisted to disk).
* Map<chatSessionId, Record<ruleKey, ruleValue>>
*/
private readonly _sessionAutoApproveRules = new Map<string, Record<string, boolean | { approve: boolean; matchCommandLine?: boolean }>>();
constructor(
@ILogService private readonly _logService: ILogService,
@ITerminalService private readonly _terminalService: ITerminalService,
@@ -66,6 +73,16 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
this._hasHiddenToolTerminalContext = TerminalChatContextKeys.hasHiddenChatTerminals.bindTo(this._contextKeyService);
this._restoreFromStorage();
// Clear session auto-approve rules when chat sessions end
this._register(this._chatService.onDidDisposeSession(e => {
for (const resource of e.sessionResource) {
const sessionId = LocalChatSessionUri.parseLocalSessionId(resource);
if (sessionId) {
this._sessionAutoApproveRules.delete(sessionId);
}
}
}));
}
registerTerminalInstanceWithToolSession(terminalToolSessionId: string | undefined, instance: ITerminalInstance): void {
@@ -313,4 +330,17 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ
hasChatSessionAutoApproval(chatSessionId: string): boolean {
return this._sessionAutoApprovalEnabled.has(chatSessionId);
}
addSessionAutoApproveRule(chatSessionId: string, key: string, value: boolean | { approve: boolean; matchCommandLine?: boolean }): void {
let sessionRules = this._sessionAutoApproveRules.get(chatSessionId);
if (!sessionRules) {
sessionRules = {};
this._sessionAutoApproveRules.set(chatSessionId, sessionRules);
}
sessionRules[key] = value;
}
getSessionAutoApproveRules(chatSessionId: string): Readonly<Record<string, boolean | { approve: boolean; matchCommandLine?: boolean }>> {
return this._sessionAutoApproveRules.get(chatSessionId) ?? {};
}
}
@@ -11,7 +11,8 @@ import { escapeRegExpCharacters, removeAnsiEscapeCodes } from '../../../../../ba
import { localize } from '../../../../../nls.js';
import type { TerminalNewAutoApproveButtonData } from '../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js';
import type { ToolConfirmationAction } from '../../../chat/common/tools/languageModelToolsService.js';
import type { ICommandApprovalResultWithReason } from './commandLineAutoApprover.js';
import type { ICommandApprovalResultWithReason } from './tools/commandLineAnalyzer/autoApprove/commandLineAutoApprover.js';
import { isAutoApproveRule } from './tools/commandLineAnalyzer/commandLineAnalyzer.js';
export function isPowerShell(envShell: string, os: OperatingSystem): boolean {
if (os === OperatingSystem.Windows) {
@@ -32,6 +33,13 @@ export function isZsh(envShell: string, os: OperatingSystem): boolean {
return /^zsh$/.test(pathPosix.basename(envShell));
}
export function isBash(envShell: string, os: OperatingSystem): boolean {
if (os === OperatingSystem.Windows) {
return /^bash(?:\.exe)?$/i.test(pathWin32.basename(envShell));
}
return /^bash$/.test(pathPosix.basename(envShell));
}
export function isFish(envShell: string, os: OperatingSystem): boolean {
if (os === OperatingSystem.Windows) {
return /^fish(?:\.exe)?$/i.test(pathWin32.basename(envShell));
@@ -105,27 +113,50 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
// instead of `foo`)
const commandsWithSubSubCommands = new Set(['npm run', 'yarn run']);
// Helper function to find the first non-flag argument after a given index
const findNextNonFlagArg = (parts: string[], startIndex: number): number | undefined => {
for (let i = startIndex; i < parts.length; i++) {
if (!parts[i].startsWith('-')) {
return i;
}
}
return undefined;
};
// For each unapproved sub-command (within the overall command line), decide whether to
// suggest new rules for the command, a sub-command, a sub-command of a sub-command or to
// not suggest at all.
//
// This includes support for detecting flags between the commands, so `mvn -DskipIT test a`
// would suggest `mvn -DskipIT test` as that's more useful than only suggesting the exact
// command line.
const subCommandsToSuggest = Array.from(new Set(coalesce(unapprovedSubCommands.map(command => {
const parts = command.trim().split(/\s+/);
const baseCommand = parts[0].toLowerCase();
const baseSubCommand = parts.length > 1 ? `${parts[0]} ${parts[1]}`.toLowerCase() : '';
// Security check: Never suggest auto-approval for dangerous interpreter commands
if (neverAutoApproveCommands.has(baseCommand)) {
return undefined;
}
if (commandsWithSubSubCommands.has(baseSubCommand)) {
if (parts.length >= 3 && !parts[2].startsWith('-')) {
return `${parts[0]} ${parts[1]} ${parts[2]}`;
}
return undefined;
} else if (commandsWithSubcommands.has(baseCommand)) {
if (parts.length >= 2 && !parts[1].startsWith('-')) {
return `${parts[0]} ${parts[1]}`;
if (commandsWithSubcommands.has(baseCommand)) {
// Find the first non-flag argument after the command
const subCommandIndex = findNextNonFlagArg(parts, 1);
if (subCommandIndex !== undefined) {
// Check if this is a sub-sub-command case
const baseSubCommand = `${parts[0]} ${parts[subCommandIndex]}`.toLowerCase();
if (commandsWithSubSubCommands.has(baseSubCommand)) {
// Look for the second non-flag argument after the first subcommand
const subSubCommandIndex = findNextNonFlagArg(parts, subCommandIndex + 1);
if (subSubCommandIndex !== undefined) {
// Include everything from command to sub-sub-command (including flags)
return parts.slice(0, subSubCommandIndex + 1).join(' ');
}
return undefined;
} else {
// Include everything from command to subcommand (including flags)
return parts.slice(0, subCommandIndex + 1).join(' ');
}
}
return undefined;
} else {
@@ -136,22 +167,48 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
if (subCommandsToSuggest.length > 0) {
let subCommandLabel: string;
if (subCommandsToSuggest.length === 1) {
subCommandLabel = localize('autoApprove.baseCommandSingle', 'Always Allow Command: {0}', subCommandsToSuggest[0]);
subCommandLabel = `\`${subCommandsToSuggest[0]} \u2026\``;
} else {
const commandSeparated = subCommandsToSuggest.join(', ');
subCommandLabel = localize('autoApprove.baseCommand', 'Always Allow Commands: {0}', commandSeparated);
subCommandLabel = `Commands ${subCommandsToSuggest.map(e => `\`${e} \u2026\``).join(', ')}`;
}
actions.push({
label: subCommandLabel,
label: `Allow ${subCommandLabel} in this Session`,
data: {
type: 'newRule',
rule: subCommandsToSuggest.map(key => ({
key,
value: true
value: true,
scope: 'session'
}))
} satisfies TerminalNewAutoApproveButtonData
});
actions.push({
label: `Allow ${subCommandLabel} in this Workspace`,
data: {
type: 'newRule',
rule: subCommandsToSuggest.map(key => ({
key,
value: true,
scope: 'workspace'
}))
} satisfies TerminalNewAutoApproveButtonData
});
actions.push({
label: `Always Allow ${subCommandLabel}`,
data: {
type: 'newRule',
rule: subCommandsToSuggest.map(key => ({
key,
value: true,
scope: 'user'
}))
} satisfies TerminalNewAutoApproveButtonData
});
}
if (actions.length > 0) {
actions.push(new Separator());
}
// Allow exact command line, don't do this if it's just the first sub-command's first
@@ -162,6 +219,34 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
!commandsWithSubcommands.has(commandLine) &&
!commandsWithSubSubCommands.has(commandLine)
) {
actions.push({
label: localize('autoApprove.exactCommand1', 'Allow Exact Command Line in this Session'),
data: {
type: 'newRule',
rule: {
key: `/^${escapeRegExpCharacters(commandLine)}$/`,
value: {
approve: true,
matchCommandLine: true
},
scope: 'session'
}
} satisfies TerminalNewAutoApproveButtonData
});
actions.push({
label: localize('autoApprove.exactCommand2', 'Allow Exact Command Line in this Workspace'),
data: {
type: 'newRule',
rule: {
key: `/^${escapeRegExpCharacters(commandLine)}$/`,
value: {
approve: true,
matchCommandLine: true
},
scope: 'workspace'
}
} satisfies TerminalNewAutoApproveButtonData
});
actions.push({
label: localize('autoApprove.exactCommand', 'Always Allow Exact Command Line'),
data: {
@@ -171,7 +256,8 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
value: {
approve: true,
matchCommandLine: true
}
},
scope: 'user'
}
} satisfies TerminalNewAutoApproveButtonData
});
@@ -207,6 +293,10 @@ export function generateAutoApproveActions(commandLine: string, subCommands: str
export function dedupeRules(rules: ICommandApprovalResultWithReason[]): ICommandApprovalResultWithReason[] {
return rules.filter((result, index, array) => {
return result.rule && array.findIndex(r => r.rule && r.rule.sourceText === result.rule!.sourceText) === index;
if (!isAutoApproveRule(result.rule)) {
return false;
}
const sourceText = result.rule.sourceText;
return array.findIndex(r => isAutoApproveRule(r.rule) && r.rule.sourceText === sourceText) === index;
});
}
@@ -9,6 +9,7 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { CancellationError } from '../../../../../base/common/errors.js';
import { Event } from '../../../../../base/common/event.js';
import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { OperatingSystem } from '../../../../../base/common/platform.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { hasKey, isNumber, isObject, isString } from '../../../../../base/common/types.js';
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
@@ -17,6 +18,8 @@ import { PromptInputState } from '../../../../../platform/terminal/common/capabi
import { ITerminalLogService, ITerminalProfile, TerminalSettingId, type IShellLaunchConfig } from '../../../../../platform/terminal/common/terminal.js';
import { ITerminalService, type ITerminalInstance } from '../../../terminal/browser/terminal.js';
import { getShellIntegrationTimeout } from '../../../terminal/common/terminalEnvironment.js';
import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js';
import { isBash, isFish, isPowerShell, isZsh } from './runInTerminalHelpers.js';
const enum ShellLaunchType {
Unknown = 0,
@@ -50,8 +53,8 @@ export class ToolTerminalCreator {
) {
}
async createTerminal(shellOrProfile: string | ITerminalProfile, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shellOrProfile);
async createTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem, token: CancellationToken): Promise<IToolTerminal> {
const instance = await this._createCopilotTerminal(shellOrProfile, os);
const toolTerminal: IToolTerminal = {
instance,
shellIntegrationQuality: ShellIntegrationQuality.None,
@@ -138,15 +141,32 @@ export class ToolTerminalCreator {
}
}
private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile) {
private _createCopilotTerminal(shellOrProfile: string | ITerminalProfile, os: OperatingSystem) {
const shellPath = isString(shellOrProfile) ? shellOrProfile : shellOrProfile.path;
const env: Record<string, string> = {
// Avoid making `git diff` interactive when called from copilot
GIT_PAGER: 'cat',
};
const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true;
if (preventShellHistory) {
// Check if the shell supports history exclusion via shell integration scripts
if (
isBash(shellPath, os) ||
isZsh(shellPath, os) ||
isFish(shellPath, os) ||
isPowerShell(shellPath, os)
) {
env['VSCODE_PREVENT_SHELL_HISTORY'] = '1';
}
}
const config: IShellLaunchConfig = {
icon: ThemeIcon.fromId(Codicon.chatSparkle.id),
hideFromUser: true,
forcePersist: true,
env: {
// Avoid making `git diff` interactive when called from copilot
GIT_PAGER: 'cat',
}
env,
};
if (isString(shellOrProfile)) {
@@ -3,27 +3,24 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../../base/common/lifecycle.js';
import type { OperatingSystem } from '../../../../../base/common/platform.js';
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../../../base/common/strings.js';
import { isObject } from '../../../../../base/common/types.js';
import { structuralEquals } from '../../../../../base/common/equals.js';
import { ConfigurationTarget, IConfigurationService, type IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js';
import { TerminalChatAgentToolsSettingId } from '../common/terminalChatAgentToolsConfiguration.js';
import { isPowerShell } from './runInTerminalHelpers.js';
export interface IAutoApproveRule {
regex: RegExp;
regexCaseInsensitive: RegExp;
sourceText: string;
sourceTarget: ConfigurationTarget;
isDefaultRule: boolean;
}
import { structuralEquals } from '../../../../../../../../base/common/equals.js';
import { Disposable } from '../../../../../../../../base/common/lifecycle.js';
import type { OperatingSystem } from '../../../../../../../../base/common/platform.js';
import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../../../../../../base/common/strings.js';
import { isObject } from '../../../../../../../../base/common/types.js';
import type { URI } from '../../../../../../../../base/common/uri.js';
import { ConfigurationTarget, IConfigurationService, type IConfigurationValue } from '../../../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../../../platform/instantiation/common/instantiation.js';
import { ITerminalChatService } from '../../../../../../terminal/browser/terminal.js';
import { TerminalChatAgentToolsSettingId } from '../../../../common/terminalChatAgentToolsConfiguration.js';
import { isPowerShell } from '../../../runInTerminalHelpers.js';
import type { IAutoApproveRule, INpmScriptAutoApproveRule } from '../commandLineAnalyzer.js';
import { NpmScriptAutoApprover } from './npmScriptAutoApprover.js';
export interface ICommandApprovalResultWithReason {
result: ICommandApprovalResult;
reason: string;
rule?: IAutoApproveRule;
rule?: IAutoApproveRule | INpmScriptAutoApproveRule;
}
export type ICommandApprovalResult = 'approved' | 'denied' | 'noMatch';
@@ -36,11 +33,15 @@ export class CommandLineAutoApprover extends Disposable {
private _allowListRules: IAutoApproveRule[] = [];
private _allowListCommandLineRules: IAutoApproveRule[] = [];
private _denyListCommandLineRules: IAutoApproveRule[] = [];
private readonly _npmScriptAutoApprover: NpmScriptAutoApprover;
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService instantiationService: IInstantiationService,
@ITerminalChatService private readonly _terminalChatService: ITerminalChatService,
) {
super();
this._npmScriptAutoApprover = this._register(instantiationService.createInstance(NpmScriptAutoApprover));
this.updateConfiguration();
this._register(this._configurationService.onDidChangeConfiguration(e => {
if (
@@ -76,7 +77,7 @@ export class CommandLineAutoApprover extends Disposable {
this._denyListCommandLineRules = denyListCommandLineRules;
}
isCommandAutoApproved(command: string, shell: string, os: OperatingSystem): ICommandApprovalResultWithReason {
async isCommandAutoApproved(command: string, shell: string, os: OperatingSystem, cwd: URI | undefined, chatSessionId?: string): Promise<ICommandApprovalResultWithReason> {
// Check if the command has a transient environment variable assignment prefix which we
// always deny for now as it can easily lead to execute other commands
if (transientEnvVarRegex.test(command)) {
@@ -86,7 +87,7 @@ export class CommandLineAutoApprover extends Disposable {
};
}
// Check the deny list to see if this command requires explicit approval
// Check the config deny list to see if this command requires explicit approval
for (const rule of this._denyListRules) {
if (this._commandMatchesRule(rule, command, shell, os)) {
return {
@@ -97,7 +98,18 @@ export class CommandLineAutoApprover extends Disposable {
}
}
// Check the allow list to see if the command is allowed to run without explicit approval
// Check session allow rules (session deny rules can't exist)
for (const rule of this._getSessionRules(chatSessionId).allowListRules) {
if (this._commandMatchesRule(rule, command, shell, os)) {
return {
result: 'approved',
rule,
reason: `Command '${command}' is approved by session allow list rule: ${rule.sourceText}`
};
}
}
// Check the config allow list to see if the command is allowed to run without explicit approval
for (const rule of this._allowListRules) {
if (this._commandMatchesRule(rule, command, shell, os)) {
return {
@@ -108,6 +120,16 @@ export class CommandLineAutoApprover extends Disposable {
}
}
// Check if this is an npm/yarn/pnpm script defined in package.json
const npmScriptResult = await this._npmScriptAutoApprover.isCommandAutoApproved(command, cwd);
if (npmScriptResult.isAutoApproved) {
return {
result: 'approved',
rule: { type: 'npmScript', npmScriptResult },
reason: `Command '${command}' is approved as npm script '${npmScriptResult.scriptName}' is defined in package.json`
};
}
// TODO: LLM-based auto-approval https://github.com/microsoft/vscode/issues/253267
// Fallback is always to require approval
@@ -117,8 +139,8 @@ export class CommandLineAutoApprover extends Disposable {
};
}
isCommandLineAutoApproved(commandLine: string): ICommandApprovalResultWithReason {
// Check the deny list first to see if this command line requires explicit approval
isCommandLineAutoApproved(commandLine: string, chatSessionId?: string): ICommandApprovalResultWithReason {
// Check the config deny list first to see if this command line requires explicit approval
for (const rule of this._denyListCommandLineRules) {
if (rule.regex.test(commandLine)) {
return {
@@ -129,7 +151,18 @@ export class CommandLineAutoApprover extends Disposable {
}
}
// Check if the full command line matches any of the allow list command line regexes
// Check session allow list (session deny rules can't exist)
for (const rule of this._getSessionRules(chatSessionId).allowListCommandLineRules) {
if (rule.regex.test(commandLine)) {
return {
result: 'approved',
rule,
reason: `Command line '${commandLine}' is approved by session allow list rule: ${rule.sourceText}`
};
}
}
// Check if the full command line matches any of the config allow list command line regexes
for (const rule of this._allowListCommandLineRules) {
if (rule.regex.test(commandLine)) {
return {
@@ -145,6 +178,54 @@ export class CommandLineAutoApprover extends Disposable {
};
}
private _getSessionRules(chatSessionId?: string): {
denyListRules: IAutoApproveRule[];
allowListRules: IAutoApproveRule[];
allowListCommandLineRules: IAutoApproveRule[];
denyListCommandLineRules: IAutoApproveRule[];
} {
const denyListRules: IAutoApproveRule[] = [];
const allowListRules: IAutoApproveRule[] = [];
const allowListCommandLineRules: IAutoApproveRule[] = [];
const denyListCommandLineRules: IAutoApproveRule[] = [];
if (!chatSessionId) {
return { denyListRules, allowListRules, allowListCommandLineRules, denyListCommandLineRules };
}
const sessionRulesConfig = this._terminalChatService.getSessionAutoApproveRules(chatSessionId);
for (const [key, value] of Object.entries(sessionRulesConfig)) {
if (typeof value === 'boolean') {
const { regex, regexCaseInsensitive } = this._convertAutoApproveEntryToRegex(key);
if (value === true) {
allowListRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
} else if (value === false) {
denyListRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
}
} else if (typeof value === 'object' && value !== null) {
const objectValue = value as { approve?: boolean; matchCommandLine?: boolean };
if (typeof objectValue.approve === 'boolean') {
const { regex, regexCaseInsensitive } = this._convertAutoApproveEntryToRegex(key);
if (objectValue.approve === true) {
if (objectValue.matchCommandLine === true) {
allowListCommandLineRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
} else {
allowListRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
}
} else if (objectValue.approve === false) {
if (objectValue.matchCommandLine === true) {
denyListCommandLineRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
} else {
denyListRules.push({ regex, regexCaseInsensitive, sourceText: key, sourceTarget: 'session', isDefaultRule: false });
}
}
}
}
}
return { denyListRules, allowListRules, allowListCommandLineRules, denyListCommandLineRules };
}
private _commandMatchesRule(rule: IAutoApproveRule, command: string, shell: string, os: OperatingSystem): boolean {
const isPwsh = isPowerShell(shell, os);
@@ -0,0 +1,232 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { MarkdownString, type IMarkdownString } from '../../../../../../../../base/common/htmlContent.js';
import { visit, type JSONVisitor } from '../../../../../../../../base/common/json.js';
import { Disposable } from '../../../../../../../../base/common/lifecycle.js';
import { URI } from '../../../../../../../../base/common/uri.js';
import { IUriIdentityService } from '../../../../../../../../platform/uriIdentity/common/uriIdentity.js';
import { localize } from '../../../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../../../platform/configuration/common/configuration.js';
import { IFileService } from '../../../../../../../../platform/files/common/files.js';
import { IWorkspaceContextService, type IWorkspaceFolder } from '../../../../../../../../platform/workspace/common/workspace.js';
import { TerminalChatAgentToolsSettingId } from '../../../../common/terminalChatAgentToolsConfiguration.js';
/**
* Regex patterns to match npm/yarn/pnpm run commands and extract the script name.
* Uses named capture groups: 'command' for the package manager, 'scriptName' for the script.
*/
const npmRunPatterns = [
// npm run <script>
// npm run-script <script>
/^(?<command>npm)\s+(?:run(?:-script)?)\s+(?<scriptName>[^\s&|;]+)/i,
// npm test, npm start, npm stop, npm restart (shorthand commands)
// See https://docs.npmjs.com/cli/v10/commands/npm-run-script
/^(?<command>npm)\s+(?<scriptName>test|start|stop|restart)\b/i,
// yarn <script>
// yarn run <script>
/^(?<command>yarn)\s+(?:run\s+)?(?<scriptName>[^\s&|;]+)/i,
// pnpm <script>
// pnpm run <script>
/^(?<command>pnpm)\s+(?:run\s+)?(?<scriptName>[^\s&|;]+)/i,
];
/**
* Yarn built-in commands that should not be treated as script names.
* Note: 'test' is omitted since it's commonly a user script, and 'yarn test'
* is often used to run the 'test' script from package.json.
*/
const yarnBuiltinCommands = new Set([
'add', 'audit', 'autoclean', 'bin', 'cache', 'check', 'config',
'create', 'dedupe', 'dlx', 'exec', 'explain', 'generate-lock-entry',
'global', 'help', 'import', 'info', 'init', 'install', 'licenses',
'link', 'list', 'login', 'logout', 'node', 'outdated', 'owner',
'pack', 'patch', 'patch-commit', 'plugin', 'policies', 'publish',
'rebuild', 'remove', 'run', 'search', 'set', 'stage', 'tag', 'team',
'unlink', 'unplug', 'up', 'upgrade', 'upgrade-interactive',
'version', 'versions', 'why', 'workspace', 'workspaces',
]);
/**
* pnpm built-in commands that should not be treated as script names.
* Note: 'test' is omitted since it's commonly a user script, and 'pnpm test'
* is often used to run the 'test' script from package.json.
*/
const pnpmBuiltinCommands = new Set([
'add', 'audit', 'bin', 'config', 'dedupe', 'deploy', 'dlx', 'doctor',
'env', 'exec', 'fetch', 'import', 'init', 'install', 'install-test',
'licenses', 'link', 'list', 'ln', 'ls', 'outdated', 'pack', 'patch',
'patch-commit', 'patch-remove', 'prune', 'publish', 'rb', 'rebuild',
'remove', 'rm', 'root', 'run', 'server', 'setup', 'store',
'un', 'uninstall', 'unlink', 'up', 'update', 'why',
]);
interface IPackageJsonScripts {
uri: URI;
scripts: Set<string>;
}
export interface INpmScriptAutoApproveResult {
isAutoApproved: boolean;
scriptName?: string;
autoApproveInfo?: IMarkdownString;
}
export class NpmScriptAutoApprover extends Disposable {
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IFileService private readonly _fileService: IFileService,
@IUriIdentityService private readonly _uriIdentityService: IUriIdentityService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
) {
super();
}
/**
* Checks if a single command is an npm/yarn/pnpm script that exists in package.json.
* Returns auto-approve result if the command is a valid script.
*/
async isCommandAutoApproved(command: string, cwd: URI | undefined): Promise<INpmScriptAutoApproveResult> {
// Check if the feature is enabled
const isNpmScriptAutoApproveEnabled = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts) === true;
if (!isNpmScriptAutoApproveEnabled) {
return { isAutoApproved: false };
}
// Extract script name from the command
const scriptName = this._extractScriptName(command);
if (!scriptName) {
return { isAutoApproved: false };
}
// Find and parse package.json
const packageJsonScripts = await this._getPackageJsonScripts(cwd);
if (!packageJsonScripts) {
return { isAutoApproved: false };
}
// Check if script exists in package.json
if (!packageJsonScripts.scripts.has(scriptName)) {
return { isAutoApproved: false };
}
// Script exists - auto approve
return {
isAutoApproved: true,
scriptName,
autoApproveInfo: new MarkdownString(
localize('autoApprove.npmScript', 'Auto approved as {0} is defined in package.json', `\`${scriptName}\``)
),
};
}
/**
* Extracts script name from an npm/yarn/pnpm run command.
*/
private _extractScriptName(command: string): string | undefined {
const trimmedCommand = command.trim();
for (const pattern of npmRunPatterns) {
const match = trimmedCommand.match(pattern);
if (match?.groups?.scriptName) {
const { command: pkgManager, scriptName } = match.groups;
// Check if this is a yarn/pnpm shorthand that matches a built-in command
if (pkgManager.toLowerCase() === 'yarn' && yarnBuiltinCommands.has(scriptName.toLowerCase())) {
continue;
}
if (pkgManager.toLowerCase() === 'pnpm' && pnpmBuiltinCommands.has(scriptName.toLowerCase())) {
continue;
}
return scriptName;
}
}
return undefined;
}
/**
* Checks if a URI is within any workspace folder.
*/
private _isWithinWorkspace(uri: URI): boolean {
const workspaceFolders = this._workspaceContextService.getWorkspace().folders;
return workspaceFolders.some((folder: IWorkspaceFolder) => this._uriIdentityService.extUri.isEqualOrParent(uri, folder.uri));
}
/**
* Finds and parses package.json to get the scripts section.
* Only looks within the workspace for security.
*/
private async _getPackageJsonScripts(cwd: URI | undefined): Promise<IPackageJsonScripts | undefined> {
// Only look in cwd if it's within the workspace
if (!cwd || !this._isWithinWorkspace(cwd)) {
return undefined;
}
const packageJsonUri = URI.joinPath(cwd, 'package.json');
const scripts = await this._readPackageJsonScripts(packageJsonUri);
if (scripts) {
return { uri: packageJsonUri, scripts };
}
return undefined;
}
/**
* Reads and parses the scripts section from a package.json file.
*/
private async _readPackageJsonScripts(packageJsonUri: URI): Promise<Set<string> | undefined> {
try {
const exists = await this._fileService.exists(packageJsonUri);
if (!exists) {
return undefined;
}
const content = await this._fileService.readFile(packageJsonUri);
const text = content.value.toString();
return this._parsePackageJsonScripts(text);
} catch {
return undefined;
}
}
/**
* Parses the scripts section from package.json content using jsonc-parser.
*/
private _parsePackageJsonScripts(content: string): Set<string> | undefined {
const scripts = new Set<string>();
let inScripts = false;
let level = 0;
const visitor: JSONVisitor = {
onError() {
// Ignore parse errors
},
onObjectBegin() {
level++;
},
onObjectEnd() {
if (inScripts && level === 2) {
inScripts = false;
}
level--;
},
onObjectProperty(property: string) {
if (level === 1 && property === 'scripts') {
inScripts = true;
} else if (inScripts && level === 2) {
scripts.add(property);
}
},
};
visit(content, visitor);
return scripts.size > 0 ? scripts : undefined;
}
}
@@ -7,9 +7,32 @@ import type { IMarkdownString } from '../../../../../../../base/common/htmlConte
import type { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import type { OperatingSystem } from '../../../../../../../base/common/platform.js';
import type { URI } from '../../../../../../../base/common/uri.js';
import type { ConfigurationTarget } from '../../../../../../../platform/configuration/common/configuration.js';
import type { ToolConfirmationAction } from '../../../../../chat/common/tools/languageModelToolsService.js';
import type { INpmScriptAutoApproveResult } from './autoApprove/npmScriptAutoApprover.js';
import type { TreeSitterCommandParserLanguage } from '../../treeSitterCommandParser.js';
export interface IAutoApproveRule {
regex: RegExp;
regexCaseInsensitive: RegExp;
sourceText: string;
sourceTarget: ConfigurationTarget | 'session';
isDefaultRule: boolean;
}
export interface INpmScriptAutoApproveRule {
type: 'npmScript';
npmScriptResult: INpmScriptAutoApproveResult;
}
export function isAutoApproveRule(rule: IAutoApproveRule | INpmScriptAutoApproveRule | undefined): rule is IAutoApproveRule {
return !!rule && 'sourceText' in rule;
}
export function isNpmScriptAutoApproveRule(rule: IAutoApproveRule | INpmScriptAutoApproveRule | undefined): rule is INpmScriptAutoApproveRule {
return !!rule && 'type' in rule && rule.type === 'npmScript';
}
export interface ICommandLineAnalyzer extends IDisposable {
analyze(options: ICommandLineAnalyzerOptions): Promise<ICommandLineAnalyzerResult>;
}
@@ -8,7 +8,7 @@ import { createCommandUri, MarkdownString, type IMarkdownString } from '../../..
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import type { SingleOrMany } from '../../../../../../../base/common/types.js';
import { localize } from '../../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { ConfigurationTarget, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
import { ITerminalChatService } from '../../../../../terminal/browser/terminal.js';
import { IStorageService, StorageScope } from '../../../../../../../platform/storage/common/storage.js';
@@ -16,12 +16,12 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../../chat/browser
import { ChatConfiguration } from '../../../../../chat/common/constants.js';
import type { ToolConfirmationAction } from '../../../../../chat/common/tools/languageModelToolsService.js';
import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js';
import { CommandLineAutoApprover, type IAutoApproveRule, type ICommandApprovalResult, type ICommandApprovalResultWithReason } from '../../commandLineAutoApprover.js';
import { dedupeRules, generateAutoApproveActions, isPowerShell } from '../../runInTerminalHelpers.js';
import type { RunInTerminalToolTelemetry } from '../../runInTerminalToolTelemetry.js';
import { type TreeSitterCommandParser } from '../../treeSitterCommandParser.js';
import type { ICommandLineAnalyzer, ICommandLineAnalyzerOptions, ICommandLineAnalyzerResult } from './commandLineAnalyzer.js';
import { type ICommandLineAnalyzer, type ICommandLineAnalyzerOptions, type ICommandLineAnalyzerResult, type IAutoApproveRule, isAutoApproveRule, isNpmScriptAutoApproveRule } from './commandLineAnalyzer.js';
import { TerminalChatCommandId } from '../../../../chat/browser/terminalChat.js';
import { CommandLineAutoApprover, type ICommandApprovalResultWithReason } from './autoApprove/commandLineAutoApprover.js';
const promptInjectionWarningCommandsLower = [
'curl',
@@ -67,9 +67,11 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
};
}
const trimmedCommandLine = options.commandLine.trimStart();
let subCommands: string[] | undefined;
try {
subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, options.commandLine);
subCommands = await this._treeSitterCommandParser.extractSubCommands(options.treeSitterLanguage, trimmedCommandLine);
this._log(`Parsed sub-commands via ${options.treeSitterLanguage} grammar`, subCommands);
} catch (e) {
console.error(e);
@@ -87,8 +89,8 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
};
}
const subCommandResults = subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os));
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(options.commandLine);
const subCommandResults = await Promise.all(subCommands.map(e => this._commandLineAutoApprover.isCommandAutoApproved(e, options.shell, options.os, options.cwd, options.chatSessionId)));
const commandLineResult = this._commandLineAutoApprover.isCommandLineAutoApproved(trimmedCommandLine, options.chatSessionId);
const autoApproveReasons: string[] = [
...subCommandResults.map(e => e.reason),
commandLineResult.reason,
@@ -102,26 +104,26 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
if (deniedSubCommandResult) {
this._log('Sub-command DENIED auto approval');
isDenied = true;
autoApproveDefault = deniedSubCommandResult.rule?.isDefaultRule;
autoApproveDefault = isAutoApproveRule(deniedSubCommandResult.rule) ? deniedSubCommandResult.rule.isDefaultRule : undefined;
autoApproveReason = 'subCommand';
} else if (commandLineResult.result === 'denied') {
this._log('Command line DENIED auto approval');
isDenied = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
autoApproveDefault = isAutoApproveRule(commandLineResult.rule) ? commandLineResult.rule.isDefaultRule : undefined;
autoApproveReason = 'commandLine';
} else {
if (subCommandResults.every(e => e.result === 'approved')) {
this._log('All sub-commands auto-approved');
autoApproveReason = 'subCommand';
isAutoApproved = true;
autoApproveDefault = subCommandResults.every(e => e.rule?.isDefaultRule);
autoApproveReason = 'subCommand';
autoApproveDefault = subCommandResults.every(e => isAutoApproveRule(e.rule) && e.rule.isDefaultRule);
} else {
this._log('All sub-commands NOT auto-approved');
if (commandLineResult.result === 'approved') {
this._log('Command line auto-approved');
autoApproveReason = 'commandLine';
isAutoApproved = true;
autoApproveDefault = commandLineResult.rule?.isDefaultRule;
autoApproveDefault = isAutoApproveRule(commandLineResult.rule) ? commandLineResult.rule.isDefaultRule : undefined;
} else {
this._log('Command line NOT auto-approved');
}
@@ -169,7 +171,7 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
}
if (!isAutoApproved && isAutoApproveEnabled) {
customActions = generateAutoApproveActions(options.commandLine, subCommands, { subCommandResults, commandLineResult });
customActions = generateAutoApproveActions(trimmedCommandLine, subCommands, { subCommandResults, commandLineResult });
}
return {
@@ -189,11 +191,36 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
subCommandResults: ICommandApprovalResultWithReason[],
commandLineResult: ICommandApprovalResultWithReason,
): IMarkdownString | undefined {
const formatRuleLinks = (result: SingleOrMany<{ result: ICommandApprovalResult; rule?: IAutoApproveRule; reason: string }>): string => {
return asArray(result).map(e => {
const settingsUri = createCommandUri(TerminalChatCommandId.OpenTerminalSettingsLink, e.rule!.sourceTarget);
return `[\`${e.rule!.sourceText}\`](${settingsUri.toString()} "${localize('ruleTooltip', 'View rule in settings')}")`;
}).join(', ');
const formatRuleLinks = (result: SingleOrMany<ICommandApprovalResultWithReason>): string => {
return asArray(result)
.filter((e): e is ICommandApprovalResultWithReason & { rule: IAutoApproveRule } =>
isAutoApproveRule(e.rule))
.map(e => {
// Session rules cannot be actioned currently so no link
if (e.rule.sourceTarget === 'session') {
return localize('autoApproveRule.sessionIndicator', '{0} (session)', `\`${e.rule.sourceText}\``);
}
const settingsUri = createCommandUri(TerminalChatCommandId.OpenTerminalSettingsLink, e.rule.sourceTarget);
const tooltip = localize('ruleTooltip', 'View rule in settings');
let label = e.rule.sourceText;
switch (e.rule?.sourceTarget) {
case ConfigurationTarget.DEFAULT:
label = `${label} (default)`;
break;
case ConfigurationTarget.USER:
case ConfigurationTarget.USER_LOCAL:
label = `${label} (user)`;
break;
case ConfigurationTarget.USER_REMOTE:
label = `${label} (remote)`;
break;
case ConfigurationTarget.WORKSPACE:
case ConfigurationTarget.WORKSPACE_FOLDER:
label = `${label} (workspace)`;
break;
}
return `[\`${label}\`](${settingsUri.toString()} "${tooltip}")`;
}).join(', ');
};
const mdTrustSettings = {
@@ -212,12 +239,17 @@ export class CommandLineAutoApproveAnalyzer extends Disposable implements IComma
if (isAutoApproved) {
switch (autoApproveReason) {
case 'commandLine': {
if (commandLineResult.rule) {
if (isAutoApproveRule(commandLineResult.rule)) {
return new MarkdownString(localize('autoApprove.rule', 'Auto approved by rule {0}', formatRuleLinks(commandLineResult)), mdTrustSettings);
}
break;
}
case 'subCommand': {
// Check if approval came from npm script
const npmScriptApproval = subCommandResults.find(e => isNpmScriptAutoApproveRule(e.rule));
if (npmScriptApproval && isNpmScriptAutoApproveRule(npmScriptApproval.rule) && npmScriptApproval.rule.npmScriptResult.autoApproveInfo) {
return npmScriptApproval.rule.npmScriptResult.autoApproveInfo;
}
const uniqueRules = dedupeRules(subCommandResults);
if (uniqueRules.length === 1) {
return new MarkdownString(localize('autoApprove.rule', 'Auto approved by rule {0}', formatRuleLinks(uniqueRules)), mdTrustSettings);
@@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { isBash, isZsh } from '../../runInTerminalHelpers.js';
import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js';
import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js';
/**
* Rewriter that prepends a space to commands to prevent them from being added to shell history for
* certain shells. This depends on $VSCODE_PREVENT_SHELL_HISTORY being handled in shell integration
* scripts to set `HISTCONTROL=ignorespace` (bash) or `HIST_IGNORE_SPACE` (zsh) env vars. The
* prepended space is harmless so we don't try to remove it if shell integration isn't functional.
*/
export class CommandLinePreventHistoryRewriter extends Disposable implements ICommandLineRewriter {
constructor(
@IConfigurationService private readonly _configurationService: IConfigurationService,
) {
super();
}
rewrite(options: ICommandLineRewriterOptions): ICommandLineRewriterResult | undefined {
const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true;
if (!preventShellHistory) {
return undefined;
}
// Only bash and zsh use space prefix to exclude from history
if (isBash(options.shell, options.os) || isZsh(options.shell, options.os)) {
return {
rewritten: ` ${options.commandLine}`,
reasoning: 'Prepended with a space to exclude from shell history'
};
}
return undefined;
}
}
@@ -47,6 +47,7 @@ import { IPollingResult, OutputMonitorState } from './monitoring/types.js';
import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js';
import type { ICommandLineRewriter } from './commandLineRewriter/commandLineRewriter.js';
import { CommandLineCdPrefixRewriter } from './commandLineRewriter/commandLineCdPrefixRewriter.js';
import { CommandLinePreventHistoryRewriter } from './commandLineRewriter/commandLinePreventHistoryRewriter.js';
import { CommandLinePwshChainOperatorRewriter } from './commandLineRewriter/commandLinePwshChainOperatorRewriter.js';
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
import { IHistoryService } from '../../../../../services/history/common/history.js';
@@ -312,6 +313,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
this._commandLineRewriters = [
this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)),
this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)),
this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)),
];
this._commandLineAnalyzers = [
this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))),
@@ -457,9 +459,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
shellType = 'pwsh';
}
const isFinalAutoApproved = (
// Is the setting enabled and the user has opted-in
isAutoApproveAllowed &&
// Check if the command would be auto-approved based on rules (ignoring warning state)
const wouldBeAutoApproved = (
// Does at least one analyzer auto approve
commandLineAnalyzerResults.some(e => e.isAutoApproved) &&
// No analyzer denies auto approval
@@ -468,7 +469,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
analyzersIsAutoApproveAllowed
);
if (isFinalAutoApproved) {
const isFinalAutoApproved = (
// Is the setting enabled and the user has opted-in
isAutoApproveAllowed &&
// Would be auto-approved based on rules
wouldBeAutoApproved
);
// Pass autoApproveInfo if command would be auto-approved (even if warning not yet accepted)
// This allows the confirmation widget to auto-approve after user accepts the warning
if (isFinalAutoApproved || (isAutoApproveEnabled && wouldBeAutoApproved)) {
toolSpecificData.autoApproveInfo = commandLineAnalyzerResults.find(e => e.autoApproveInfo)?.autoApproveInfo;
}
@@ -792,7 +802,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
private async _initBackgroundTerminal(chatSessionId: string, termId: string, terminalToolSessionId: string | undefined, token: CancellationToken): Promise<IToolTerminal> {
this._logService.debug(`RunInTerminalTool: Creating background terminal with ID=${termId}`);
const profile = await this._profileFetcher.getCopilotProfile();
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token);
const os = await this._osBackend;
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token);
this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance);
this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance);
this._registerInputListener(toolTerminal);
@@ -814,7 +825,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
return cachedTerminal;
}
const profile = await this._profileFetcher.getCopilotProfile();
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, token);
const os = await this._osBackend;
const toolTerminal = await this._terminalToolCreator.createTerminal(profile, os, token);
this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance);
this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionId, toolTerminal.instance);
this._registerInputListener(toolTerminal);
@@ -957,7 +969,7 @@ class BackgroundTerminalExecution extends Disposable {
private readonly _xterm: XtermTerminal,
private readonly _commandLine: string,
readonly sessionId: string,
commandId?: string
commandId?: string,
) {
super();
@@ -15,11 +15,13 @@ import { PolicyCategory } from '../../../../../base/common/policy.js';
export const enum TerminalChatAgentToolsSettingId {
EnableAutoApprove = 'chat.tools.terminal.enableAutoApprove',
AutoApprove = 'chat.tools.terminal.autoApprove',
AutoApproveWorkspaceNpmScripts = 'chat.tools.terminal.autoApproveWorkspaceNpmScripts',
IgnoreDefaultAutoApproveRules = 'chat.tools.terminal.ignoreDefaultAutoApproveRules',
BlockDetectedFileWrites = 'chat.tools.terminal.blockDetectedFileWrites',
ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout',
AutoReplyToPrompts = 'chat.tools.terminal.autoReplyToPrompts',
OutputLocation = 'chat.tools.terminal.outputLocation',
PreventShellHistory = 'chat.tools.terminal.preventShellHistory',
TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux',
TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx',
@@ -195,22 +197,24 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
//
// Safe and common sub-commands
'git status': true,
'git log': true,
'git show': true,
'git diff': true,
// Note: These patterns support `-C <path>` and `--no-pager` immediately after `git`
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true,
// git grep
// - `--open-files-in-pager`: This is the configured pager, so no risk of code execution
// - See notes on `grep`
'git grep': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true,
// git branch
// - `-d`, `-D`, `--delete`: Prevent branch deletion
// - `-m`, `-M`: Prevent branch renaming
// - `--force`: Generally dangerous
'git branch': true,
'/^git branch\\b.*-(d|D|m|M|-delete|-force)\\b/': false,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true,
'/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*-(d|D|m|M|-delete|-force)\\b/': false,
// #endregion
@@ -223,6 +227,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
'Get-Location': true,
'Write-Host': true,
'Write-Output': true,
'Out-String': true,
'Split-Path': true,
'Join-Path': true,
'Start-Sleep': true,
@@ -355,6 +360,15 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
tags: ['experimental'],
markdownDescription: localize('ignoreDefaultAutoApproveRules.description', "Whether to ignore the built-in default auto-approve rules used by the run in terminal tool as defined in {0}. When this setting is enabled, the run in terminal tool will ignore any rule that comes from the default set but still follow rules defined in the user, remote and workspace settings. Use this setting at your own risk; the default auto-approve rules are designed to protect you against running dangerous commands.", `\`#${TerminalChatAgentToolsSettingId.AutoApprove}#\``),
},
[TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts]: {
restricted: true,
type: 'boolean',
// In order to use agent mode the workspace must be trusted, this plus the fact that
// modifying package.json is protected means this is safe to enable by default.
default: true,
tags: ['experimental'],
markdownDescription: localize('autoApproveWorkspaceNpmScripts.description', "Whether to automatically approve npm, yarn, and pnpm run commands when the script is defined in a workspace package.json file. Since the workspace is trusted, scripts defined in package.json are considered safe to run without explicit approval."),
},
[TerminalChatAgentToolsSettingId.BlockDetectedFileWrites]: {
type: 'string',
enum: ['never', 'outsideWorkspace', 'all'],
@@ -445,6 +459,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
experiment: {
mode: 'auto'
}
},
[TerminalChatAgentToolsSettingId.PreventShellHistory]: {
type: 'boolean',
default: true,
markdownDescription: [
localize('preventShellHistory.description', "Whether to exclude commands run by the terminal tool from the shell history. See below for the supported shells and the method used for each:"),
`- \`bash\`: ${localize('preventShellHistory.description.bash', "Sets `HISTCONTROL=ignorespace` and prepends the command with space")}`,
`- \`zsh\`: ${localize('preventShellHistory.description.zsh', "Sets `HIST_IGNORE_SPACE` option and prepends the command with space")}`,
`- \`fish\`: ${localize('preventShellHistory.description.fish', "Sets `fish_private_mode` to prevent any command from entering history")}`,
`- \`pwsh\`: ${localize('preventShellHistory.description.pwsh', "Sets a custom history handler via PSReadLine's `AddToHistoryHandler` to prevent any command from entering history")}`,
].join('\n'),
}
};
@@ -4,11 +4,12 @@
*--------------------------------------------------------------------------------------------*/
import { ok, strictEqual } from 'assert';
import { TRUNCATION_MESSAGE, dedupeRules, isPowerShell, sanitizeTerminalOutput, truncateOutputKeepingTail } from '../../browser/runInTerminalHelpers.js';
import { generateAutoApproveActions, TRUNCATION_MESSAGE, dedupeRules, isPowerShell, sanitizeTerminalOutput, truncateOutputKeepingTail } from '../../browser/runInTerminalHelpers.js';
import { OperatingSystem } from '../../../../../../base/common/platform.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js';
import type { IAutoApproveRule, ICommandApprovalResultWithReason } from '../../browser/commandLineAutoApprover.js';
import type { ICommandApprovalResultWithReason } from '../../browser/tools/commandLineAnalyzer/autoApprove/commandLineAutoApprover.js';
import { isAutoApproveRule, type IAutoApproveRule } from '../../browser/tools/commandLineAnalyzer/commandLineAnalyzer.js';
suite('isPowerShell', () => {
ensureNoDisposablesAreLeakedInTestSuite();
@@ -186,6 +187,10 @@ suite('dedupeRules', () => {
};
}
function getSourceText(result: ICommandApprovalResultWithReason): string | undefined {
return isAutoApproveRule(result.rule) ? result.rule.sourceText : undefined;
}
test('should return empty array for empty input', () => {
const result = dedupeRules([]);
strictEqual(result.length, 0);
@@ -197,8 +202,8 @@ suite('dedupeRules', () => {
createMockResult('approved', 'approved by ls rule', createMockRule('ls'))
]);
strictEqual(result.length, 2);
strictEqual(result[0].rule?.sourceText, 'echo');
strictEqual(result[1].rule?.sourceText, 'ls');
strictEqual(getSourceText(result[0]), 'echo');
strictEqual(getSourceText(result[1]), 'ls');
});
test('should deduplicate rules with same sourceText', () => {
@@ -208,8 +213,8 @@ suite('dedupeRules', () => {
createMockResult('approved', 'approved by ls rule', createMockRule('ls'))
]);
strictEqual(result.length, 2);
strictEqual(result[0].rule?.sourceText, 'echo');
strictEqual(result[1].rule?.sourceText, 'ls');
strictEqual(getSourceText(result[0]), 'echo');
strictEqual(getSourceText(result[1]), 'ls');
});
test('should preserve first occurrence when deduplicating', () => {
@@ -228,7 +233,7 @@ suite('dedupeRules', () => {
createMockResult('denied', 'denied without rule')
]);
strictEqual(result.length, 1);
strictEqual(result[0].rule?.sourceText, 'echo');
strictEqual(getSourceText(result[0]), 'echo');
});
test('should handle mix of rules and no-rule results with duplicates', () => {
@@ -240,8 +245,8 @@ suite('dedupeRules', () => {
createMockResult('denied', 'denied without rule')
]);
strictEqual(result.length, 2);
strictEqual(result[0].rule?.sourceText, 'echo');
strictEqual(result[1].rule?.sourceText, 'ls');
strictEqual(getSourceText(result[0]), 'echo');
strictEqual(getSourceText(result[1]), 'ls');
});
test('should handle multiple duplicates of same rule', () => {
@@ -252,9 +257,9 @@ suite('dedupeRules', () => {
createMockResult('approved', 'git rule', createMockRule('git'))
]);
strictEqual(result.length, 2);
strictEqual(result[0].rule?.sourceText, 'npm');
strictEqual(getSourceText(result[0]), 'npm');
strictEqual(result[0].reason, 'npm rule 1');
strictEqual(result[1].rule?.sourceText, 'git');
strictEqual(getSourceText(result[1]), 'git');
});
});
@@ -287,3 +292,175 @@ suite('sanitizeTerminalOutput', () => {
ok(result.endsWith('line'));
});
});
suite('generateAutoApproveActions', () => {
ensureNoDisposablesAreLeakedInTestSuite();
function createMockRule(sourceText: string): IAutoApproveRule {
// Escape special regex characters for test purposes to prevent regex errors
const escapedText = sourceText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return {
regex: new RegExp(escapedText),
regexCaseInsensitive: new RegExp(escapedText, 'i'),
sourceText,
sourceTarget: ConfigurationTarget.USER,
isDefaultRule: false
};
}
function createMockResult(result: 'approved' | 'denied' | 'noMatch', reason: string, rule?: IAutoApproveRule): ICommandApprovalResultWithReason {
return {
result,
reason,
rule
};
}
test('should suggest mvn test when command is mvn test', () => {
const commandLine = 'mvn test';
const subCommands = ['mvn test'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('mvn test'));
ok(subCommandAction, 'Should suggest mvn test approval');
});
test('should suggest mvn -DskipIT test when flags appear before subcommand', () => {
const commandLine = 'mvn -DskipIT test';
const subCommands = ['mvn -DskipIT test'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('mvn -DskipIT test'));
ok(subCommandAction, 'Should suggest mvn -DskipIT test approval (including flags)');
});
test('should suggest mvn -X -DskipIT test when multiple flags appear before subcommand', () => {
const commandLine = 'mvn -X -DskipIT test';
const subCommands = ['mvn -X -DskipIT test'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('mvn -X -DskipIT test'));
ok(subCommandAction, 'Should suggest mvn -X -DskipIT test approval with multiple flags');
});
test('should suggest gradle --info build when flags appear before subcommand', () => {
const commandLine = 'gradle --info build';
const subCommands = ['gradle --info build'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('gradle --info build'));
ok(subCommandAction, 'Should suggest gradle --info build approval');
});
test('should suggest npm --silent run test when flags appear before subcommand', () => {
const commandLine = 'npm --silent run test';
const subCommands = ['npm --silent run test'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('npm --silent run test'));
ok(subCommandAction, 'Should suggest npm --silent run test approval (sub-sub-command with flags)');
});
test('should suggest npm --silent run --verbose test when flags appear between subcommands', () => {
const commandLine = 'npm --silent run --verbose test';
const subCommands = ['npm --silent run --verbose test'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('npm --silent run --verbose test'));
ok(subCommandAction, 'Should suggest npm --silent run --verbose test with flags between subcommands');
});
test('should not suggest approval when only flags and no subcommand', () => {
const commandLine = 'mvn -X -DskipIT';
const subCommands = ['mvn -X -DskipIT'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('Always Allow Command:') && action.label.includes('mvn'));
strictEqual(subCommandAction, undefined, 'Should not suggest mvn approval when no subcommand found');
});
test('should suggest exact command line when subcommand cannot be extracted', () => {
const commandLine = 'mvn -X -DskipIT';
const subCommands = ['mvn -X -DskipIT'];
const autoApproveResult = {
subCommandResults: [createMockResult('noMatch', 'not approved')],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const exactCommandAction = actions.find(action => action.label.includes('Always Allow Exact Command Line'));
ok(exactCommandAction, 'Should suggest exact command line approval');
});
test('should handle multiple subcommands with flags', () => {
const commandLine = 'mvn -DskipIT test && gradle --info build';
const subCommands = ['mvn -DskipIT test', 'gradle --info build'];
const autoApproveResult = {
subCommandResults: [
createMockResult('noMatch', 'not approved'),
createMockResult('noMatch', 'not approved')
],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action =>
action.label.includes('mvn -DskipIT test') && action.label.includes('gradle --info build')
);
ok(subCommandAction, 'Should suggest both mvn -DskipIT test and gradle --info build');
});
test('should not suggest when commands are denied', () => {
const commandLine = 'mvn -DskipIT test';
const subCommands = ['mvn -DskipIT test'];
const autoApproveResult = {
subCommandResults: [createMockResult('denied', 'denied by rule', createMockRule('mvn test'))],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('Always Allow Command:'));
strictEqual(subCommandAction, undefined, 'Should not suggest approval for denied commands');
});
test('should not suggest when commands are already approved', () => {
const commandLine = 'mvn -DskipIT test';
const subCommands = ['mvn -DskipIT test'];
const autoApproveResult = {
subCommandResults: [createMockResult('approved', 'approved by rule', createMockRule('mvn test'))],
commandLineResult: createMockResult('noMatch', 'not approved')
};
const actions = generateAutoApproveActions(commandLine, subCommands, autoApproveResult);
const subCommandAction = actions.find(action => action.label.includes('mvn -DskipIT test') && action.label.includes('Always Allow Command:'));
strictEqual(subCommandAction, undefined, 'Should not suggest approval for already approved commands');
});
});
@@ -0,0 +1,202 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { strictEqual } from 'assert';
import { VSBuffer } from '../../../../../../../base/common/buffer.js';
import { Schemas } from '../../../../../../../base/common/network.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { FileService } from '../../../../../../../platform/files/common/fileService.js';
import { IFileService } from '../../../../../../../platform/files/common/files.js';
import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js';
import type { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { NullLogService } from '../../../../../../../platform/log/common/log.js';
import { IWorkspaceContextService, toWorkspaceFolder } from '../../../../../../../platform/workspace/common/workspace.js';
import { Workspace } from '../../../../../../../platform/workspace/test/common/testWorkspace.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { TestIPCFileSystemProvider } from '../../../../../../test/electron-browser/workbenchTestServices.js';
import { TestContextService } from '../../../../../../test/common/workbenchTestServices.js';
import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js';
import { NpmScriptAutoApprover } from '../../../browser/tools/commandLineAnalyzer/autoApprove/npmScriptAutoApprover.js';
suite('NpmScriptAutoApprover', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();
let instantiationService: TestInstantiationService;
let approver: NpmScriptAutoApprover;
let configurationService: TestConfigurationService;
let workspaceContextService: TestContextService;
let fileService: FileService;
let fileSystemProvider: InMemoryFileSystemProvider;
// Use inMemory scheme to avoid platform-specific path issues
const cwd = URI.from({ scheme: Schemas.inMemory, path: '/workspace/project' });
setup(async () => {
fileService = store.add(new FileService(new NullLogService()));
// Register file: scheme provider for tree-sitter WASM grammar loading
store.add(fileService.registerProvider(Schemas.file, new TestIPCFileSystemProvider()));
// Register inMemory: scheme provider for test package.json files
fileSystemProvider = store.add(new InMemoryFileSystemProvider());
store.add(fileService.registerProvider(Schemas.inMemory, fileSystemProvider));
// Create workspace directory structure
await fileService.createFolder(cwd);
configurationService = new TestConfigurationService();
workspaceContextService = new TestContextService();
instantiationService = workbenchInstantiationService({
fileService: () => fileService,
configurationService: () => configurationService
}, store);
instantiationService.stub(IWorkspaceContextService, workspaceContextService);
instantiationService.stub(IFileService, fileService);
approver = store.add(instantiationService.createInstance(NpmScriptAutoApprover));
// Enable npm script auto-approve by default for tests
configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts, true);
// Setup workspace
const workspace = new Workspace('test', [toWorkspaceFolder(cwd)]);
workspaceContextService.setWorkspace(workspace);
});
async function writePackageJson(uri: URI, scripts: Record<string, string>) {
const packageJson = {
name: 'test-project',
version: '1.0.0',
scripts
};
await fileService.writeFile(uri, VSBuffer.fromString(JSON.stringify(packageJson, null, 2)));
}
async function t(command: string, scripts: Record<string, string>, expectedAutoApproved: boolean) {
const packageJsonUri = URI.joinPath(cwd, 'package.json');
await writePackageJson(packageJsonUri, scripts);
const result = await approver.isCommandAutoApproved(command, cwd);
strictEqual(result.isAutoApproved, expectedAutoApproved, `Expected isAutoApproved to be ${expectedAutoApproved} for: ${command}`);
}
suite('npm run commands', () => {
test('npm run build - script exists', () => t('npm run build', { build: 'tsc' }, true));
test('npm run test - script exists', () => t('npm run test', { test: 'jest' }, true));
test('npm run dev - script exists', () => t('npm run dev', { dev: 'vite' }, true));
test('npm run start - script exists', () => t('npm run start', { start: 'node index.js' }, true));
test('npm run lint - script exists', () => t('npm run lint', { lint: 'eslint .' }, true));
test('npm run-script build - script exists', () => t('npm run-script build', { build: 'tsc' }, true));
// npm shorthand commands (npm test, npm start, npm stop, npm restart)
test('npm test - shorthand script exists', () => t('npm test', { test: 'jest' }, true));
test('npm start - shorthand script exists', () => t('npm start', { start: 'node index.js' }, true));
test('npm stop - shorthand script exists', () => t('npm stop', { stop: 'pkill node' }, true));
test('npm restart - shorthand script exists', () => t('npm restart', { restart: 'npm stop && npm start' }, true));
test('npm test - shorthand script does not exist', () => t('npm test', { build: 'tsc' }, false));
test('npm test -- --watch - shorthand with args', () => t('npm test -- --watch', { test: 'jest' }, true));
test('npm startevil - word boundary prevents match', () => t('npm startevil', { start: 'node index.js', startevil: 'evil' }, false));
test('npm install - built-in command, not a script', () => t('npm install', { install: 'echo should not match' }, false));
// Scripts with colons (namespaced scripts)
test('npm run build:prod - script with colon exists', () => t('npm run build:prod', { 'build:prod': 'tsc --build' }, true));
test('npm run test:unit - script with colon exists', () => t('npm run test:unit', { 'test:unit': 'jest --testPathPattern=unit' }, true));
test('npm run lint:fix - script with colon exists', () => t('npm run lint:fix', { 'lint:fix': 'eslint . --fix' }, true));
test('npm run missing - script does not exist', () => t('npm run missing', { build: 'tsc' }, false));
test('npm run build - no scripts section', async () => {
const packageJsonUri = URI.joinPath(cwd, 'package.json');
await fileService.writeFile(packageJsonUri, VSBuffer.fromString(JSON.stringify({ name: 'test' })));
const result = await approver.isCommandAutoApproved('npm run build', cwd);
strictEqual(result.isAutoApproved, false);
});
});
suite('yarn commands', () => {
test('yarn run build - script exists', () => t('yarn run build', { build: 'tsc' }, true));
test('yarn run test - script exists', () => t('yarn run test', { test: 'jest' }, true));
// Yarn shorthand (yarn <script>)
test('yarn build - script exists (shorthand)', () => t('yarn build', { build: 'tsc' }, true));
test('yarn test - script exists (shorthand)', () => t('yarn test', { test: 'jest' }, true));
// Yarn built-in commands should not match
test('yarn install - built-in command, not a script', () => t('yarn install', { install: 'echo should not match' }, false));
test('yarn add - built-in command, not a script', () => t('yarn add lodash', { add: 'echo should not match' }, false));
test('yarn run missing - script does not exist', () => t('yarn run missing', { build: 'tsc' }, false));
});
suite('pnpm commands', () => {
test('pnpm run build - script exists', () => t('pnpm run build', { build: 'tsc' }, true));
test('pnpm run test - script exists', () => t('pnpm run test', { test: 'jest' }, true));
// pnpm shorthand (pnpm <script>)
test('pnpm build - script exists (shorthand)', () => t('pnpm build', { build: 'tsc' }, true));
test('pnpm test - script exists (shorthand)', () => t('pnpm test', { test: 'jest' }, true));
// pnpm built-in commands should not match
test('pnpm install - built-in command, not a script', () => t('pnpm install', { install: 'echo should not match' }, false));
test('pnpm add - built-in command, not a script', () => t('pnpm add lodash', { add: 'echo should not match' }, false));
test('pnpm run missing - script does not exist', () => t('pnpm run missing', { build: 'tsc' }, false));
});
suite('no package.json', () => {
test('npm run build - no package.json file', async () => {
const result = await approver.isCommandAutoApproved('npm run build', URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/path' }));
strictEqual(result.isAutoApproved, false);
});
});
suite('non-npm commands', () => {
test('git status - not an npm command', () => t('git status', { build: 'tsc' }, false));
test('ls -la - not an npm command', () => t('ls -la', { build: 'tsc' }, false));
test('echo hello - not an npm command', () => t('echo hello', { build: 'tsc' }, false));
});
suite('auto-approve disabled', () => {
test('npm run build - npm script auto-approve setting disabled', async () => {
configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AutoApproveWorkspaceNpmScripts, false);
const packageJsonUri = URI.joinPath(cwd, 'package.json');
await writePackageJson(packageJsonUri, { build: 'tsc' });
const result = await approver.isCommandAutoApproved('npm run build', cwd);
strictEqual(result.isAutoApproved, false);
});
});
suite('autoApproveInfo message', () => {
test('single script - message contains script name', async () => {
const packageJsonUri = URI.joinPath(cwd, 'package.json');
await writePackageJson(packageJsonUri, { build: 'tsc' });
const result = await approver.isCommandAutoApproved('npm run build', cwd);
strictEqual(result.isAutoApproved, true);
strictEqual(result.scriptName, 'build', 'Should return script name');
strictEqual(result.autoApproveInfo?.value.includes('build'), true, 'Should mention script name');
strictEqual(result.autoApproveInfo?.value.includes('package.json'), true, 'Should mention package.json');
});
});
suite('workspace folder security', () => {
test('cwd outside workspace - does not auto-approve', async () => {
// Create package.json outside workspace
const outsideCwd = URI.from({ scheme: Schemas.inMemory, path: '/outside/project' });
await fileService.createFolder(outsideCwd);
const outsidePackageJsonUri = URI.joinPath(outsideCwd, 'package.json');
await writePackageJson(outsidePackageJsonUri, { build: 'tsc' });
const result = await approver.isCommandAutoApproved('npm run build', outsideCwd);
strictEqual(result.isAutoApproved, false, 'Should not auto-approve when cwd is outside workspace');
});
});
});
@@ -10,7 +10,7 @@ import { Emitter } from '../../../../../../base/common/event.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js';
import { count } from '../../../../../../base/common/strings.js';
import type { SingleOrMany } from '../../../../../../base/common/types.js';
import { hasKey, type SingleOrMany } from '../../../../../../base/common/types.js';
import { URI } from '../../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { ITreeSitterLibraryService } from '../../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
@@ -82,6 +82,9 @@ suite('RunInTerminalTool', () => {
fileService: () => fileService,
}, store);
instantiationService.stub(IChatService, {
onDidDisposeSession: chatServiceDisposeEmitter.event
});
instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService)));
instantiationService.stub(IWorkspaceContextService, workspaceContextService);
instantiationService.stub(IHistoryService, {
@@ -101,9 +104,6 @@ suite('RunInTerminalTool', () => {
onDidDisposeInstance: terminalServiceDisposeEmitter.event,
setNextCommandId: async () => { }
});
instantiationService.stub(IChatService, {
onDidDisposeSession: chatServiceDisposeEmitter.event
});
instantiationService.stub(ITerminalProfileResolverService, {
getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile)
});
@@ -227,6 +227,7 @@ suite('RunInTerminalTool', () => {
'Get-Location',
'Write-Host "Hello"',
'Write-Output "Test"',
'Out-String',
'Split-Path C:\\Users\\test',
'Join-Path C:\\Users test',
'Start-Sleep 2',
@@ -493,7 +494,9 @@ suite('RunInTerminalTool', () => {
suite('prepareToolInvocation - custom actions for dropdown', () => {
function assertDropdownActions(result: IPreparedToolInvocation | undefined, items: ({ subCommand: SingleOrMany<string> } | 'commandLine' | '---' | 'configure' | 'sessionApproval')[]) {
type ActionItemType = { subCommand: SingleOrMany<string>; scope: 'session' | 'workspace' | 'user' } | { commandLine: true; scope: 'session' | 'workspace' | 'user' } | '---' | 'configure' | 'sessionApproval';
function assertDropdownActions(result: IPreparedToolInvocation | undefined, items: ActionItemType[]) {
const actions = result?.confirmationMessages?.terminalCustomActions!;
ok(actions, 'Expected custom actions to be defined');
@@ -511,16 +514,21 @@ suite('RunInTerminalTool', () => {
} else if (item === 'sessionApproval') {
strictEqual(action.label, 'Allow All Commands in this Session');
strictEqual(action.data.type, 'sessionApproval');
} else if (item === 'commandLine') {
strictEqual(action.label, 'Always Allow Exact Command Line');
} else if (hasKey(item, { commandLine: true })) {
const expectedLabel = item.scope === 'session' ? 'Allow Exact Command Line in this Session'
: item.scope === 'workspace' ? 'Allow Exact Command Line in this Workspace'
: 'Always Allow Exact Command Line';
strictEqual(action.label, expectedLabel);
strictEqual(action.data.type, 'newRule');
ok(!Array.isArray(action.data.rule), 'Expected rule to be an object');
} else {
if (Array.isArray(item.subCommand)) {
strictEqual(action.label, `Always Allow Commands: ${item.subCommand.join(', ')}`);
} else {
strictEqual(action.label, `Always Allow Command: ${item.subCommand}`);
}
const subCommandLabel = Array.isArray(item.subCommand)
? `Commands ${item.subCommand.map(e => `\`${e} \u2026\``).join(', ')}`
: `\`${item.subCommand} \u2026\``;
const expectedLabel = item.scope === 'session' ? `Allow ${subCommandLabel} in this Session`
: item.scope === 'workspace' ? `Allow ${subCommandLabel} in this Workspace`
: `Always Allow ${subCommandLabel}`;
strictEqual(action.label, expectedLabel);
strictEqual(action.data.type, 'newRule');
ok(Array.isArray(action.data.rule), 'Expected rule to be an array');
}
@@ -539,8 +547,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: 'npm run build' },
'commandLine',
{ subCommand: 'npm run build', scope: 'session' },
{ subCommand: 'npm run build', scope: 'workspace' },
{ subCommand: 'npm run build', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -556,7 +569,10 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'foo' },
{ subCommand: 'foo', scope: 'session' },
{ subCommand: 'foo', scope: 'workspace' },
{ subCommand: 'foo', scope: 'user' },
'---',
'---',
'sessionApproval',
'---',
@@ -601,8 +617,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: ['npm install', 'npm run build'] },
'commandLine',
{ subCommand: ['npm install', 'npm run build'], scope: 'session' },
{ subCommand: ['npm install', 'npm run build'], scope: 'workspace' },
{ subCommand: ['npm install', 'npm run build'], scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -621,8 +642,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: 'foo' },
'commandLine',
{ subCommand: 'foo', scope: 'session' },
{ subCommand: 'foo', scope: 'workspace' },
{ subCommand: 'foo', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -655,8 +681,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result, 'Run `bash` command?');
assertDropdownActions(result, [
{ subCommand: ['foo', 'bar'] },
'commandLine',
{ subCommand: ['foo', 'bar'], scope: 'session' },
{ subCommand: ['foo', 'bar'], scope: 'workspace' },
{ subCommand: ['foo', 'bar'], scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -672,8 +703,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'git status' },
'commandLine',
{ subCommand: 'git status', scope: 'session' },
{ subCommand: 'git status', scope: 'workspace' },
{ subCommand: 'git status', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -689,8 +725,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'npm test' },
'commandLine',
{ subCommand: 'npm test', scope: 'session' },
{ subCommand: 'npm test', scope: 'workspace' },
{ subCommand: 'npm test', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -706,8 +747,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'npm run build' },
'commandLine',
{ subCommand: 'npm run build', scope: 'session' },
{ subCommand: 'npm run build', scope: 'workspace' },
{ subCommand: 'npm run build', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -723,8 +769,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'yarn run test' },
'commandLine',
{ subCommand: 'yarn run test', scope: 'session' },
{ subCommand: 'yarn run test', scope: 'workspace' },
{ subCommand: 'yarn run test', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -740,8 +791,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'foo' },
'commandLine',
{ subCommand: 'foo', scope: 'session' },
{ subCommand: 'foo', scope: 'workspace' },
{ subCommand: 'foo', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -757,8 +813,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'npm run abc' },
'commandLine',
{ subCommand: 'npm run abc', scope: 'session' },
{ subCommand: 'npm run abc', scope: 'workspace' },
{ subCommand: 'npm run abc', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -774,8 +835,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: ['npm run build', 'git status'] },
'commandLine',
{ subCommand: ['npm run build', 'git status'], scope: 'session' },
{ subCommand: ['npm run build', 'git status'], scope: 'workspace' },
{ subCommand: ['npm run build', 'git status'], scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -791,8 +857,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: ['git push', 'echo'] },
'commandLine',
{ subCommand: ['git push', 'echo'], scope: 'session' },
{ subCommand: ['git push', 'echo'], scope: 'workspace' },
{ subCommand: ['git push', 'echo'], scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -808,8 +879,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: ['git status', 'git log'] },
'commandLine',
{ subCommand: ['git status', 'git log'], scope: 'session' },
{ subCommand: ['git status', 'git log'], scope: 'workspace' },
{ subCommand: ['git status', 'git log'], scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -825,8 +901,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'foo' },
'commandLine',
{ subCommand: 'foo', scope: 'session' },
{ subCommand: 'foo', scope: 'workspace' },
{ subCommand: 'foo', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -856,8 +937,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'npm test' },
'commandLine',
{ subCommand: 'npm test', scope: 'session' },
{ subCommand: 'npm test', scope: 'workspace' },
{ subCommand: 'npm test', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -873,8 +959,13 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
{ subCommand: 'foo' },
'commandLine',
{ subCommand: 'foo', scope: 'session' },
{ subCommand: 'foo', scope: 'workspace' },
{ subCommand: 'foo', scope: 'user' },
'---',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -890,7 +981,9 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(result);
assertDropdownActions(result, [
'commandLine',
{ commandLine: true, scope: 'session' },
{ commandLine: true, scope: 'workspace' },
{ commandLine: true, scope: 'user' },
'---',
'sessionApproval',
'---',
@@ -1018,6 +1111,23 @@ suite('RunInTerminalTool', () => {
assertConfirmationRequired(await executeToolTest({ command: 'echo hello world' }), 'Run `bash` command?');
});
test('should include autoApproveInfo when command would be auto-approved but warning not accepted', async () => {
setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true);
setAutoApprove({
echo: true
});
clearAutoApproveWarningAcceptedState();
const result = await executeToolTest({ command: 'echo hello world' });
assertConfirmationRequired(result, 'Run `bash` command?');
// autoApproveInfo should be set so the confirmation widget knows to auto-approve
// after the user accepts the warning modal
const terminalData = result!.toolSpecificData as IChatTerminalToolInvocationData;
ok(terminalData.autoApproveInfo, 'autoApproveInfo should be set for commands that would be auto-approved');
});
test('should auto-approve commands when both auto-approve enabled and warning accepted', async () => {
setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true);
setAutoApprove({
@@ -484,10 +484,11 @@ suite('Terminal history', () => {
if (!isWindows) {
suite('local', () => {
let originalEnvValues: { HOME: string | undefined };
let originalEnvValues: { HOME: string | undefined; XDG_DATA_HOME: string | undefined };
setup(() => {
originalEnvValues = { HOME: env['HOME'] };
originalEnvValues = { HOME: env['HOME'], XDG_DATA_HOME: env['XDG_DATA_HOME'] };
env['HOME'] = '/home/user';
delete env['XDG_DATA_HOME'];
remoteConnection = { remoteAuthority: 'some-remote' };
fileScheme = Schemas.vscodeRemote;
filePath = '/home/user/.local/share/fish/fish_history';
@@ -498,6 +499,11 @@ suite('Terminal history', () => {
} else {
env['HOME'] = originalEnvValues['HOME'];
}
if (originalEnvValues['XDG_DATA_HOME'] === undefined) {
delete env['XDG_DATA_HOME'];
} else {
env['XDG_DATA_HOME'] = originalEnvValues['XDG_DATA_HOME'];
}
});
test('current OS', async () => {
filePath = '/home/user/.local/share/fish/fish_history';
@@ -528,10 +534,11 @@ suite('Terminal history', () => {
});
}
suite('remote', () => {
let originalEnvValues: { HOME: string | undefined };
let originalEnvValues: { HOME: string | undefined; XDG_DATA_HOME: string | undefined };
setup(() => {
originalEnvValues = { HOME: env['HOME'] };
originalEnvValues = { HOME: env['HOME'], XDG_DATA_HOME: env['XDG_DATA_HOME'] };
env['HOME'] = '/home/user';
delete env['XDG_DATA_HOME'];
remoteConnection = { remoteAuthority: 'some-remote' };
fileScheme = Schemas.vscodeRemote;
filePath = '/home/user/.local/share/fish/fish_history';
@@ -542,6 +549,11 @@ suite('Terminal history', () => {
} else {
env['HOME'] = originalEnvValues['HOME'];
}
if (originalEnvValues['XDG_DATA_HOME'] === undefined) {
delete env['XDG_DATA_HOME'];
} else {
env['XDG_DATA_HOME'] = originalEnvValues['XDG_DATA_HOME'];
}
});
test('Windows', async () => {
remoteEnvironment = { os: OperatingSystem.Windows };
@@ -50,7 +50,7 @@ export class TerminalStickyScrollOverlay extends Disposable {
private readonly _xtermAddonLoader = new XtermAddonImporter();
private _serializeAddon?: SerializeAddonType;
private _webglAddon?: WebglAddonType;
private readonly _webglAddon: MutableDisposable<WebglAddonType> = this._register(new MutableDisposable());
private _webglAddonCustomGlyphs?: boolean;
private _ligaturesAddon?: LigaturesAddonType;
@@ -497,18 +497,19 @@ export class TerminalStickyScrollOverlay extends Disposable {
@throttle(0)
private async _refreshGpuAcceleration() {
if (this._shouldLoadWebgl() && (!this._webglAddon || this._webglAddonCustomGlyphs !== this._terminalConfigurationService.config.customGlyphs)) {
if (this._shouldLoadWebgl() && (!this._webglAddon.value || this._webglAddonCustomGlyphs !== this._terminalConfigurationService.config.customGlyphs)) {
const WebglAddon = await this._xtermAddonLoader.importAddon('webgl');
if (this._store.isDisposed) {
return;
}
this._webglAddon = this._register(new WebglAddon({
// Dispose of existing addon before creating a new one to avoid leaking WebGL contexts
this._webglAddon.value = new WebglAddon({
customGlyphs: this._terminalConfigurationService.config.customGlyphs
}));
this._stickyScrollOverlay?.loadAddon(this._webglAddon);
} else if (!this._shouldLoadWebgl() && this._webglAddon) {
this._webglAddon.dispose();
this._webglAddon = undefined;
});
this._webglAddonCustomGlyphs = this._terminalConfigurationService.config.customGlyphs;
this._stickyScrollOverlay?.loadAddon(this._webglAddon.value);
} else if (!this._shouldLoadWebgl() && this._webglAddon.value) {
this._webglAddon.clear();
}
}
@@ -269,6 +269,11 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
const resourceCompletions: ITerminalCompletion[] = [];
const cursorPrefix = promptValue.substring(0, cursorPosition);
// Determine if we're completing the command (first word) vs an argument
// We're in command position if there are no unescaped spaces before cursor
const wordsBeforeCursor = cursorPrefix.split(/(?<!\\) /);
const isCommandPosition = wordsBeforeCursor.length <= 1 && !cursorPrefix.endsWith(' ');
// TODO: Leverage Fig's tokens array here?
// The last word (or argument). When the cursor is following a space it will be the empty
// string
@@ -309,12 +314,55 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
// Determine the current folder being shown
let lastWordFolderResource: URI | string | undefined;
const lastWordFolderHasDotPrefix = !!lastWordFolder.match(/^\.\.?[\\\/]/);
const lastWordFolderHasTildePrefix = !!lastWordFolder.match(/^~[\\\/]?/);
const isAbsolutePath = getIsAbsolutePath(shellType, resourceOptions.pathSeparator, lastWordFolder, useWindowsStylePath);
const type = lastWordFolderHasTildePrefix ? 'tilde' : isAbsolutePath ? 'absolute' : 'relative';
const cwd = URI.revive(resourceOptions.cwd);
let lastWordFolderResource: URI | string | undefined;
if (type === 'relative' && lastWordFolder.length > 0) {
// If the typed folder matches the tail of cwd (common when the extension already
// resolved the path, such as `./src/vs/`), reuse cwd to avoid duplicating segments.
const normalizedFolder = (useWindowsStylePath ? lastWordFolder.replaceAll('\\', '/') : lastWordFolder).replaceAll('\\ ', ' ');
const hasDotPrefix = normalizedFolder.startsWith('./');
if (hasDotPrefix) {
const stripped = normalizedFolder.replace(/^\.\/+/, '').replace(/\/+$/, '');
if (stripped) {
const cwdParts = cwd.path.replace(/\/+$/, '').split('/');
const strippedParts = stripped.split('/');
const tailMatches = strippedParts.length <= cwdParts.length && strippedParts.every((part, idx) => cwdParts[cwdParts.length - strippedParts.length + idx] === part);
if (tailMatches) {
try {
await this._fileService.stat(cwd);
lastWordFolderResource = cwd;
} catch {
return undefined;
}
}
}
}
// Otherwise resolve the folder relative to cwd.
if (!lastWordFolderResource) {
const folderToResolve = URI.joinPath(cwd, normalizedFolder);
try {
await this._fileService.stat(folderToResolve);
lastWordFolderResource = folderToResolve;
} catch {
return undefined;
}
}
} else if (type === 'relative') {
lastWordFolderResource = cwd;
}
if (type === 'relative' && !lastWordFolderResource) {
try {
await this._fileService.stat(cwd);
lastWordFolderResource = cwd;
} catch {
return undefined;
}
}
switch (type) {
case 'tilde': {
@@ -340,7 +388,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
break;
}
case 'relative': {
lastWordFolderResource = cwd;
lastWordFolderResource ??= cwd;
break;
}
}
@@ -363,7 +411,10 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
return resourceCompletions;
}
const stat = await this._fileService.resolve(lastWordFolderResource, { resolveSingleChildDescendants: true });
const stat = await this._fileService.resolve(lastWordFolderResource, {
resolveMetadata: true,
resolveSingleChildDescendants: true
});
if (!stat?.children) {
return;
}
@@ -425,6 +476,12 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
kind = TerminalCompletionItemKind.Folder;
}
} else if (showFiles && child.isFile) {
// When completing the command (first word) on Unix, only show executable files
if (isCommandPosition && !useWindowsStylePath) {
if (!child.executable) {
return;
}
}
if (child.isSymbolicLink) {
kind = TerminalCompletionItemKind.SymbolicLinkFile;
} else {
@@ -96,7 +96,7 @@ suite('TerminalCompletionService', () => {
let configurationService: TestConfigurationService;
let capabilities: TerminalCapabilityStore;
let validResources: URI[];
let childResources: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean }[];
let childResources: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean; executable?: boolean }[];
let terminalCompletionService: TerminalCompletionService;
const provider = 'testProvider';
@@ -104,17 +104,22 @@ suite('TerminalCompletionService', () => {
instantiationService = workbenchInstantiationService({
pathService: () => new TestPathService(URI.file(homeDir ?? '/')),
}, store);
const normalizePath = (path: string) => path === '/' ? path : path.replace(/\/+$/, '');
const doesResourceExist = (resource: URI) => validResources.some(e => normalizePath(e.path) === normalizePath(resource.path)) || childResources.some(e => normalizePath(e.resource.path) === normalizePath(resource.path));
configurationService = new TestConfigurationService();
instantiationService.stub(ITerminalLogService, new NullLogService());
instantiationService.stub(IConfigurationService, configurationService);
instantiationService.stub(IFileService, {
async stat(resource) {
if (!validResources.map(e => e.path).includes(resource.path)) {
if (!doesResourceExist(resource)) {
throw new Error('Doesn\'t exist');
}
return createFileStat(resource);
},
async resolve(resource: URI, options: IResolveMetadataFileOptions): Promise<IFileStatWithMetadata> {
if (!doesResourceExist(resource)) {
throw new Error('Doesn\'t exist');
}
const children = childResources.filter(child => {
const childFsPath = child.resource.path.replace(/\/$/, '');
const parentFsPath = resource.path.replace(/\/$/, '');
@@ -227,10 +232,10 @@ suite('TerminalCompletionService', () => {
setup(() => {
validResources = [URI.parse('file:///test')];
childResources = [
{ resource: URI.parse('file:///test/.hiddenFile'), isFile: true },
{ resource: URI.parse('file:///test/.hiddenFile'), isFile: true, executable: true },
{ resource: URI.parse('file:///test/.hiddenFolder/'), isDirectory: true },
{ resource: URI.parse('file:///test/folder1/'), isDirectory: true },
{ resource: URI.parse('file:///test/file1.txt'), isFile: true },
{ resource: URI.parse('file:///test/file1.txt'), isFile: true, executable: true },
];
});
@@ -302,7 +307,7 @@ suite('TerminalCompletionService', () => {
childResources = [
{ resource: URI.parse('file:///home/vscode'), isDirectory: true },
{ resource: URI.parse('file:///home/vscode/foo'), isDirectory: true },
{ resource: URI.parse('file:///home/vscode/bar.txt'), isFile: true },
{ resource: URI.parse('file:///home/vscode/bar.txt'), isFile: true, executable: true },
];
});
@@ -484,6 +489,22 @@ suite('TerminalCompletionService', () => {
], { replacementRange: [0, 2] });
});
test('should not return completions when relative folder prefix does not exist', async () => {
const resourceOptions: TerminalCompletionResourceOptions = {
cwd: URI.parse('file:///test'),
showDirectories: true,
pathSeparator
};
validResources = [URI.parse('file:///test')];
childResources = [
{ resource: URI.parse('file:///test/src/'), isDirectory: true },
{ resource: URI.parse('file:///test/vs/'), isDirectory: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, 's/', 2, provider, capabilities);
assert.strictEqual(result, undefined);
});
test('./| should handle large directories with many results gracefully', async () => {
const resourceOptions: TerminalCompletionResourceOptions = {
cwd: URI.parse('file:///test'),
@@ -524,6 +545,28 @@ suite('TerminalCompletionService', () => {
{ label: './../', detail: '/' }
], { replacementRange: [1, 10] });
});
test('should resolve nested folder when name matches cwd basename', async () => {
const resourceOptions: TerminalCompletionResourceOptions = {
cwd: URI.parse('file:///test'),
showDirectories: true,
pathSeparator
};
validResources = [
URI.parse('file:///test'),
URI.parse('file:///test/test'),
];
childResources = [
{ resource: URI.parse('file:///test/test/'), isDirectory: true },
{ resource: URI.parse('file:///test/test/inner/'), isDirectory: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, 'test/', 5, provider, capabilities);
assertCompletions(result, [
{ label: './test/', detail: '/test/test/' },
{ label: './test/inner/', detail: '/test/test/inner/' },
{ label: './test/../', detail: '/' }
], { replacementRange: [0, 5] });
});
test('test/| should normalize current and parent folders', async () => {
const resourceOptions: TerminalCompletionResourceOptions = {
cwd: URI.parse('file:///test'),
@@ -539,14 +582,14 @@ suite('TerminalCompletionService', () => {
{ resource: URI.parse('file:///test/folder1/'), isDirectory: true },
{ resource: URI.parse('file:///test/folder2/'), isDirectory: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, 'test/', 5, provider, capabilities);
const result = await terminalCompletionService.resolveResources(resourceOptions, './test/', 7, provider, capabilities);
assertCompletions(result, [
{ label: './test/', detail: '/test/' },
{ label: './test/folder1/', detail: '/test/folder1/' },
{ label: './test/folder2/', detail: '/test/folder2/' },
{ label: './test/../', detail: '/' }
], { replacementRange: [0, 5] });
], { replacementRange: [0, 7] });
});
});
@@ -554,7 +597,10 @@ suite('TerminalCompletionService', () => {
let shellEnvDetection: ShellEnvDetectionCapability;
setup(() => {
validResources = [URI.parse('file:///test')];
validResources = [
URI.parse('file:///test'),
URI.parse('file:///cdpath_value')
];
childResources = [
{ resource: URI.parse('file:///cdpath_value/folder1/'), isDirectory: true },
{ resource: URI.parse('file:///cdpath_value/file1.txt'), isFile: true },
@@ -666,7 +712,7 @@ suite('TerminalCompletionService', () => {
];
childResources = [
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true, isFile: false },
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true, executable: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, 'C:/Users/foo/', 13, provider, capabilities, WindowsShellType.GitBash);
assertCompletions(result, [
@@ -689,7 +735,7 @@ suite('TerminalCompletionService', () => {
];
childResources = [
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true, executable: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, './', 2, provider, capabilities, WindowsShellType.GitBash);
assertCompletions(result, [
@@ -714,7 +760,7 @@ suite('TerminalCompletionService', () => {
];
childResources = [
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true, executable: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, '/c/Users/foo/', 13, provider, capabilities, WindowsShellType.GitBash);
assertCompletions(result, [
@@ -768,7 +814,7 @@ suite('TerminalCompletionService', () => {
{ resource: URI.parse('file:///test/[folder1]/'), isDirectory: true },
{ resource: URI.parse('file:///test/folder 2/'), isDirectory: true },
{ resource: URI.parse('file:///test/!special$chars&/'), isDirectory: true },
{ resource: URI.parse('file:///test/!special$chars2&'), isFile: true }
{ resource: URI.parse('file:///test/!special$chars2&'), isFile: true, executable: true }
];
const result = await terminalCompletionService.resolveResources(resourceOptions, '', 0, provider, capabilities);
@@ -2319,6 +2319,7 @@ suite('EditorService', () => {
isSymbolicLink: false,
readonly: false,
locked: false,
executable: false,
children: undefined
}));
await activeEditorChangePromise;
@@ -512,6 +512,7 @@ suite('HistoryService', function () {
name: 'other.txt',
readonly: false,
locked: false,
executable: false,
size: 0,
resource: toResource.call(this, '/path/other.txt'),
children: undefined
@@ -109,7 +109,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
}
readonly defaultKeybindingsResource = URI.from({ scheme: network.Schemas.vscode, authority: 'defaultsettings', path: '/keybindings.json' });
private readonly defaultSettingsRawResource = URI.from({ scheme: network.Schemas.vscode, authority: 'defaultsettings', path: '/defaultSettings.json' });
private readonly defaultSettingsRawResource = URI.from({ scheme: network.Schemas.vscode, authority: 'defaultsettings', path: '/defaultSettings.jsonc' });
get userSettingsResource(): URI {
return this.userDataProfileService.currentProfile.settingsResource;
@@ -371,7 +371,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
value: buffer,
encoding: preferredEncoding.encoding,
readonly: false,
locked: false
locked: false,
executable: false
}, true /* dirty (resolved from buffer) */, options);
}
@@ -419,7 +420,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
value: await createTextBufferFactoryFromStream(await this.textFileService.getDecodedStream(this.resource, backup.value, { encoding: UTF8 })),
encoding,
readonly: false,
locked: false
locked: false,
executable: false
}, true /* dirty (resolved from backup) */, options);
// Restore orphaned flag based on state
@@ -517,6 +519,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil
etag: content.etag,
readonly: content.readonly,
locked: content.locked,
executable: false,
isFile: true,
isDirectory: false,
isSymbolicLink: false,
@@ -527,7 +527,8 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
etag,
value: buffer,
readonly: false,
locked: false
locked: false,
executable: false
}, true /* dirty (resolved from buffer) */);
}
@@ -568,7 +569,8 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
etag: backup.meta ? backup.meta.etag : ETAG_DISABLED, // etag disabled if unknown!
value: backup.value,
readonly: false,
locked: false
locked: false,
executable: false
}, true /* dirty (resolved from backup) */);
// Restore orphaned flag based on state
@@ -664,6 +666,7 @@ export class StoredFileWorkingCopy<M extends IStoredFileWorkingCopyModel> extend
etag: content.etag,
readonly: content.readonly,
locked: content.locked,
executable: false,
isFile: true,
isDirectory: false,
isSymbolicLink: false,
@@ -118,6 +118,7 @@ export class TestStoredFileWorkingCopyModelWithCustomSave extends TestStoredFile
isSymbolicLink: false,
readonly: false,
locked: false,
executable: false,
children: undefined
};
}
@@ -487,7 +487,8 @@ export class TestTextFileService extends BrowserTextFileService {
value: await createTextBufferFactoryFromStream(content.value),
size: 10,
readonly: false,
locked: false
locked: false,
executable: false
};
}
@@ -245,7 +245,7 @@ export class TestWorkingCopy extends Disposable implements IWorkingCopy {
}
}
export function createFileStat(resource: URI, readonly = false, isFile?: boolean, isDirectory?: boolean, isSymbolicLink?: boolean, children?: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean }[] | undefined): IFileStatWithMetadata {
export function createFileStat(resource: URI, readonly = false, isFile?: boolean, isDirectory?: boolean, isSymbolicLink?: boolean, children?: { resource: URI; isFile?: boolean; isDirectory?: boolean; isSymbolicLink?: boolean; executable?: boolean }[] | undefined, executable?: boolean): IFileStatWithMetadata {
return {
resource,
etag: Date.now().toString(),
@@ -257,8 +257,9 @@ export function createFileStat(resource: URI, readonly = false, isFile?: boolean
isSymbolicLink: isSymbolicLink ?? false,
readonly,
locked: false,
executable: executable ?? false,
name: basename(resource),
children: children?.map(c => createFileStat(c.resource, false, c.isFile, c.isDirectory, c.isSymbolicLink)),
children: children?.map(c => createFileStat(c.resource, false, c.isFile, c.isDirectory, c.isSymbolicLink, undefined, c.executable)),
};
}