diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md new file mode 100644 index 0000000000000..20f207907ac8c --- /dev/null +++ b/.agents/skills/launch/SKILL.md @@ -0,0 +1,374 @@ +--- +name: launch +description: "Launch and automate VS Code (Code OSS) using agent-browser via Chrome DevTools Protocol. Use when you need to interact with the VS Code UI, automate the chat panel, test UI features, or take screenshots of VS Code. Triggers include 'automate VS Code', 'interact with chat', 'test the UI', 'take a screenshot', 'launch Code OSS with debugging'." +metadata: + allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +--- + +# VS Code Automation + +Automate VS Code (Code OSS) using agent-browser. VS Code is built on Electron/Chromium and exposes a Chrome DevTools Protocol (CDP) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages. + +## Prerequisites + +- **`agent-browser` must be installed.** It's listed in devDependencies — run `npm install` in the repo root. Use `npx agent-browser` if it's not on your PATH, or install globally with `npm install -g agent-browser`. +- **For Code OSS (VS Code dev build):** The repo must be built before launching. `./scripts/code.sh` runs the build automatically if needed, or set `VSCODE_SKIP_PRELAUNCH=1` to skip the compile step if you've already built. +- **CSS selectors are internal implementation details.** Selectors like `.interactive-input-part`, `.interactive-input-editor`, and `.part.auxiliarybar` used in `eval` commands are VS Code internals that may change across versions. If they stop working, use `agent-browser snapshot -i` to re-discover the current DOM structure. + +## Core Workflow + +1. **Launch** Code OSS with remote debugging enabled +2. **Connect** agent-browser to the CDP port +3. **Snapshot** to discover interactive elements +4. **Interact** using element refs +5. **Re-snapshot** after navigation or state changes + +> **📸 Take screenshots for a paper trail.** Use `agent-browser screenshot ` at key moments — after launch, before/after interactions, and when something goes wrong. Screenshots provide visual proof of what the UI looked like and are invaluable for debugging failures or documenting what was accomplished. +> +> Save screenshots inside a timestamped subfolder so each run is isolated and nothing gets overwritten: +> +> ```bash +> # Create a timestamped folder for this run's screenshots +> SCREENSHOT_DIR="/tmp/code-oss-screenshots/$(date +%Y-%m-%dT%H-%M-%S)" +> mkdir -p "$SCREENSHOT_DIR" +> +> # Save a screenshot (path is a positional argument — use ./ or absolute paths) +> # Bare filenames without ./ may be misinterpreted as CSS selectors +> agent-browser screenshot "$SCREENSHOT_DIR/after-launch.png" +> ``` + +```bash +# Launch Code OSS with remote debugging +./scripts/code.sh --remote-debugging-port=9224 + +# Wait for Code OSS to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done + +# Verify you're connected to the right target (not about:blank) +# If `tab` shows the wrong target, run `agent-browser close` and reconnect +agent-browser tab + +# Discover UI elements +agent-browser snapshot -i + +# Focus the chat input (macOS) +agent-browser press Control+Meta+i +``` + +## Connecting + +```bash +# Connect to a specific port +agent-browser connect 9222 + +# Or use --cdp on each command +agent-browser --cdp 9222 snapshot -i + +# Auto-discover a running Chromium-based app +agent-browser --auto-connect snapshot -i +``` + +After `connect`, all subsequent commands target the connected app without needing `--cdp`. + +## Tab Management + +Electron apps often have multiple windows or webviews. Use tab commands to list and switch between them: + +```bash +# List all available targets (windows, webviews, etc.) +agent-browser tab + +# Switch to a specific tab by index +agent-browser tab 2 + +# Switch by URL pattern +agent-browser tab --url "*settings*" +``` + +## Launching Code OSS (VS Code Dev Build) + +The VS Code repository includes `scripts/code.sh` which launches Code OSS from source. It passes all arguments through to the Electron binary, so `--remote-debugging-port` works directly: + +```bash +cd # the root of your VS Code checkout +./scripts/code.sh --remote-debugging-port=9224 +``` + +Wait for the window to fully initialize, then connect: + +```bash +# Wait for Code OSS to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done + +# Verify you're connected to the right target (not about:blank) +# If `tab` shows the wrong target, run `agent-browser close` and reconnect +agent-browser tab +agent-browser snapshot -i +``` + +**Tips:** +- Set `VSCODE_SKIP_PRELAUNCH=1` to skip the compile step if you've already built: `VSCODE_SKIP_PRELAUNCH=1 ./scripts/code.sh --remote-debugging-port=9224` (from the repo root) +- Code OSS uses the default user data directory. Unlike VS Code Insiders, you don't typically need `--user-data-dir` since there's usually only one Code OSS instance running. +- If you see "Sent env to running instance. Terminating..." it means Code OSS is already running and forwarded your args to the existing instance. Quit Code OSS and relaunch with the flag, or use `--user-data-dir=/tmp/code-oss-debug` to force a new instance. + +## Launching the Sessions App (Agent Sessions Window) + +The Sessions app is a separate workbench mode launched with the `--sessions` flag. It uses a dedicated user data directory to avoid conflicts with the main Code OSS instance. + +```bash +cd # the root of your VS Code checkout +./scripts/code.sh --sessions --remote-debugging-port=9224 +``` + +Wait for the window to fully initialize, then connect: + +```bash +# Wait for Sessions app to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done + +# Verify you're connected to the right target (not about:blank) +agent-browser tab +agent-browser snapshot -i +``` + +**Tips:** +- The `--sessions` flag launches the Agent Sessions workbench instead of the standard VS Code workbench. +- Set `VSCODE_SKIP_PRELAUNCH=1` to skip the compile step if you've already built. + +## Launching VS Code Extensions for Debugging + +To debug a VS Code extension via agent-browser, launch VS Code Insiders with `--extensionDevelopmentPath` and `--remote-debugging-port`. Use `--user-data-dir` to avoid conflicting with an already-running instance. + +```bash +# Build the extension first +cd # e.g., the root of your extension checkout +npm run compile + +# Launch VS Code Insiders with the extension and CDP +code-insiders \ + --extensionDevelopmentPath="" \ + --remote-debugging-port=9223 \ + --user-data-dir=/tmp/vscode-ext-debug + +# Wait for VS Code to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9223 2>/dev/null && break || sleep 3; done + +# Verify you're connected to the right target (not about:blank) +# If `tab` shows the wrong target, run `agent-browser close` and reconnect +agent-browser tab +agent-browser snapshot -i +``` + +**Key flags:** +- `--extensionDevelopmentPath=` — loads your extension from source (must be compiled first) +- `--remote-debugging-port=9223` — enables CDP (use 9223 to avoid conflicts with other apps on 9222) +- `--user-data-dir=` — uses a separate profile so it starts a new process instead of sending to an existing VS Code instance + +**Without `--user-data-dir`**, VS Code detects the running instance, forwards the args to it, and exits immediately — you'll see "Sent env to running instance. Terminating..." and CDP never starts. + +## Restarting After Code Changes + +**After making changes to Code OSS source code, you must restart to pick up the new build.** The workbench loads the compiled JavaScript at startup — changes are not hot-reloaded. + +### Restart Workflow + +1. **Rebuild** the changed code +2. **Kill** the running Code OSS instance +3. **Relaunch** with the same flags + +```bash +# 1. Ensure your build is up to date. +# Normally you can skip a manual step here and let ./scripts/code.sh in step 3 +# trigger the build when needed (or run `npm run watch` in another terminal). + +# 2. Kill the Code OSS instance listening on the debug port (if running) +pids=$(lsof -t -i :9224) +if [ -n "$pids" ]; then + kill $pids +fi + +# 3. Relaunch +./scripts/code.sh --remote-debugging-port=9224 + +# 4. Reconnect agent-browser +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done +agent-browser tab +agent-browser snapshot -i +``` + +> **Tip:** If you're iterating frequently, run `npm run watch` in a separate terminal so compilation happens automatically. You still need to kill and relaunch Code OSS to load the new build. + +## Interacting with Monaco Editor (Chat Input, Code Editors) + +VS Code uses Monaco Editor for all text inputs including the Copilot Chat input. Monaco editors require specific agent-browser techniques — standard `click`, `fill`, and `keyboard type` commands may not work depending on the VS Code build. + +### The Universal Pattern: Focus via Keyboard Shortcut + `press` + +This works on **all** VS Code builds (Code OSS, Insiders, stable): + +```bash +# 1. Open and focus the chat input with the keyboard shortcut +# macOS: +agent-browser press Control+Meta+i +# Linux / Windows: +agent-browser press Control+Alt+i + +# 2. Type using individual press commands +agent-browser press H +agent-browser press e +agent-browser press l +agent-browser press l +agent-browser press o +agent-browser press Space # Use "Space" for spaces +agent-browser press w +agent-browser press o +agent-browser press r +agent-browser press l +agent-browser press d + +# Verify text appeared (optional) +agent-browser eval ' +(() => { + const sidebar = document.querySelector(".part.auxiliarybar"); + const viewLines = sidebar.querySelectorAll(".interactive-input-editor .view-line"); + return Array.from(viewLines).map(vl => vl.textContent).join("|"); +})()' + +# 3. Send the message (same on all platforms) +agent-browser press Enter +``` + +**Chat focus shortcut by platform:** +- **macOS:** `Ctrl+Cmd+I` → `agent-browser press Control+Meta+i` +- **Linux:** `Ctrl+Alt+I` → `agent-browser press Control+Alt+i` +- **Windows:** `Ctrl+Alt+I` → `agent-browser press Control+Alt+i` + +This shortcut focuses the chat input and sets `document.activeElement` to a `DIV` with class `native-edit-context` — VS Code's native text editing surface that correctly processes key events from `agent-browser press`. + +### `type @ref` — Works on Some Builds + +On VS Code Insiders (extension debug mode), `type @ref` handles focus and input in one step: + +```bash +agent-browser snapshot -i +# Look for: textbox "The editor is not accessible..." [ref=e62] +agent-browser type @e62 "Hello from George!" +``` + +> **Tip:** If `type @ref` silently drops text (the editor stays empty), the ref may be stale or the editor not yet ready. Re-snapshot to get a fresh ref and try again. You can verify text was entered using the snippet in "Verifying Text and Clearing" below. + +However, **`type @ref` silently fails on Code OSS** — the command completes without error but no text appears. This also applies to `keyboard type` and `keyboard inserttext`. Always verify text appeared after typing, and fall back to the keyboard shortcut + `press` pattern if it didn't. The `press`-per-key approach works universally across all builds. + +> **⚠️ Warning:** `keyboard type` can hang indefinitely in some focus states (e.g., after JS mouse events). If it doesn't return within a few seconds, interrupt it and fall back to `press` for individual keystrokes. + +### Compatibility Matrix + +| Method | VS Code Insiders | Code OSS | +|--------|-----------------|----------| +| `press` per key (after focus shortcut) | ✅ Works | ✅ Works | +| `type @ref` | ✅ Works | ❌ Silent fail | +| `keyboard type` (after focus) | ✅ Works | ❌ Silent fail | +| `keyboard inserttext` (after focus) | ✅ Works | ❌ Silent fail | +| `click @ref` | ❌ Blocked by overlay | ❌ Blocked by overlay | +| `fill @ref` | ❌ Element not visible | ❌ Element not visible | + +### Fallback: Focus via JavaScript Mouse Events + +If the keyboard shortcut doesn't work (e.g., chat panel isn't configured), you can focus the editor via JavaScript: + +```bash +agent-browser eval ' +(() => { + const inputPart = document.querySelector(".interactive-input-part"); + const editor = inputPart.querySelector(".monaco-editor"); + const rect = editor.getBoundingClientRect(); + const x = rect.x + rect.width / 2; + const y = rect.y + rect.height / 2; + editor.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: x, clientY: y })); + editor.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientX: x, clientY: y })); + editor.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y })); + return "activeElement: " + document.activeElement?.className; +})()' + +# Then use press for each character +agent-browser press H +agent-browser press e +# ... +``` + +### Verifying Text and Clearing + +```bash +# Verify text in the chat input +agent-browser eval ' +(() => { + const sidebar = document.querySelector(".part.auxiliarybar"); + const viewLines = sidebar.querySelectorAll(".interactive-input-editor .view-line"); + return Array.from(viewLines).map(vl => vl.textContent).join("|"); +})()' + +# Clear the input (Select All + Backspace) +# macOS: +agent-browser press Meta+a +# Linux / Windows: +agent-browser press Control+a +# Then delete: +agent-browser press Backspace +``` + +### Screenshot Tips for VS Code + +On ultrawide monitors, the chat sidebar may be in the far-right corner of the CDP screenshot. Options: +- Use `agent-browser screenshot --full` to capture the entire window +- Use element screenshots: `agent-browser screenshot ".part.auxiliarybar" sidebar.png` +- Use `agent-browser screenshot --annotate` to see labeled element positions +- Maximize the sidebar first: click the "Maximize Secondary Side Bar" button + +> **macOS:** If `agent-browser screenshot` returns "Permission denied", your terminal needs Screen Recording permission. Grant it in **System Settings → Privacy & Security → Screen Recording**. As a fallback, use the `eval` verification snippet to confirm text was entered — this doesn't require screen permissions. + +## Troubleshooting + +### "Connection refused" or "Cannot connect" + +- Make sure Code OSS was launched with `--remote-debugging-port=NNNN` +- If Code OSS was already running, quit and relaunch with the flag +- Check that the port isn't in use by another process: + - macOS / Linux: `lsof -i :9224` + - Windows: `netstat -ano | findstr 9224` + +### Elements not appearing in snapshot + +- VS Code uses multiple webviews. Use `agent-browser tab` to list targets and switch to the right one +- Use `agent-browser snapshot -i -C` to include cursor-interactive elements (divs with onclick handlers) + +### Cannot type in Monaco Editor inputs + +- Use `agent-browser press` for individual keystrokes after focusing the input. Focus the chat input with the keyboard shortcut (macOS: `Ctrl+Cmd+I`, Linux/Windows: `Ctrl+Alt+I`). +- `type @ref`, `keyboard type`, and `keyboard inserttext` work on VS Code Insiders but **silently fail on Code OSS** — they complete without error but no text appears. The `press`-per-key approach works universally. +- See the "Interacting with Monaco Editor" section above for the full compatibility matrix. + +## Cleanup + +**Always kill the Code OSS instance when you're done.** Code OSS is a full Electron app that consumes significant memory (often 1–4 GB+). Leaving it running wastes resources and holds the CDP port. + +```bash +# Disconnect agent-browser +agent-browser close + +# Kill the Code OSS instance listening on the debug port (if running) +# macOS / Linux: +pids=$(lsof -t -i :9224) +if [ -n "$pids" ]; then + kill $pids +fi + +# Windows: +# taskkill /F /PID +# Or use Task Manager to end "Code - OSS" +``` + +Verify it's gone: +```bash +# Confirm no process is listening on the debug port +lsof -i :9224 # should return nothing +``` diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 120000 index ff807266877bc..0000000000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../.github/copilot-instructions.md \ No newline at end of file diff --git a/.eslint-ignore b/.eslint-ignore index 4736eb5621dd7..8b8cdd1c2c707 100644 --- a/.eslint-ignore +++ b/.eslint-ignore @@ -18,8 +18,6 @@ **/extensions/terminal-suggest/src/shell/fishBuiltinsCache.ts **/extensions/terminal-suggest/third_party/** **/extensions/typescript-language-features/test-workspace/** -**/extensions/typescript-language-features/extension.webpack.config.js -**/extensions/typescript-language-features/extension-browser.webpack.config.js **/extensions/typescript-language-features/package-manager/node-maintainer/** **/extensions/vscode-api-tests/testWorkspace/** **/extensions/vscode-api-tests/testWorkspace2/** diff --git a/.eslint-plugin-local/code-no-icons-in-localized-strings.ts b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts new file mode 100644 index 0000000000000..8f4251dfd415a --- /dev/null +++ b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import { TSESTree } from '@typescript-eslint/utils'; + +/** + * Prevents theme icon syntax `$(iconName)` from appearing inside localized + * string arguments. Localizers may translate or corrupt the icon syntax, + * breaking rendering. Icon references should be kept outside the localized + * string - either prepended via concatenation or passed as a placeholder + * argument. + * + * Examples: + * ❌ localize('key', "$(gear) Settings") + * ✅ '$(gear) ' + localize('key', "Settings") + * ✅ localize('key', "Like {0}", '$(gear)') + * + * ❌ nls.localize('key', "$(loading~spin) Loading...") + * ✅ '$(loading~spin) ' + nls.localize('key', "Loading...") + */ +export default new class NoIconsInLocalizedStrings implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noIconInLocalizedString: 'Theme icon syntax $(…) should not appear inside localized strings. Move it outside the localize call or pass it as a placeholder argument.' + }, + docs: { + description: 'Prevents $(icon) theme icon syntax inside localize() string arguments', + }, + type: 'problem', + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + // Matches $(iconName) or $(iconName~modifier) but not escaped \$(...) + const iconPattern = /(? checkCallExpression(node as TSESTree.CallExpression) + }; + } +}; diff --git a/.eslint-plugin-local/code-no-static-node-module-import.ts b/.eslint-plugin-local/code-no-static-node-module-import.ts new file mode 100644 index 0000000000000..674e5f9e6eb30 --- /dev/null +++ b/.eslint-plugin-local/code-no-static-node-module-import.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import * as eslint from 'eslint'; +import { builtinModules } from 'module'; +import { join, normalize, relative } from 'path'; +import minimatch from 'minimatch'; +import { createImportRuleListener } from './utils.ts'; + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map(m => `node:${m}`) +]); + +const REPO_ROOT = normalize(join(import.meta.dirname, '../')); + +export default new class implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + staticImport: 'Static imports of \'{{module}}\' are not allowed here because they are loaded synchronously on startup. Use a dynamic `await import(...)` or `import type` instead.' + }, + docs: { + description: 'Disallow static imports of node_modules packages to prevent synchronous loading on startup. Allows Node.js built-ins, electron, relative imports, and whitelisted file paths.' + }, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + const allowedPaths = context.options as string[]; + const filePath = normalize(relative(REPO_ROOT, normalize(context.getFilename()))).replace(/\\/g, '/'); + + // Skip whitelisted files + if (allowedPaths.some(pattern => filePath === pattern || minimatch(filePath, pattern))) { + return {}; + } + + return createImportRuleListener((node, value) => { + // Allow `import type` and `export type` declarations + if (node.parent?.type === TSESTree.AST_NODE_TYPES.ImportDeclaration && node.parent.importKind === 'type') { + return; + } + if (node.parent && 'exportKind' in node.parent && node.parent.exportKind === 'type') { + return; + } + + // Allow relative imports + if (value.startsWith('.')) { + return; + } + + // Allow Node.js built-in modules + if (nodeBuiltins.has(value)) { + return; + } + + // Allow electron + if (value === 'electron') { + return; + } + + context.report({ + loc: node.parent!.loc, + messageId: 'staticImport', + data: { + module: value + } + }); + }); + } +}; diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts new file mode 100644 index 0000000000000..2627a09c0a492 --- /dev/null +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import type * as ESTree from 'estree'; + +const telemetryMethods = new Set(['publicLog', 'publicLog2', 'publicLogError', 'publicLogError2']); + +/** + * Common telemetry property names that are automatically added to every event. + * Telemetry events must not set these because they would collide with / be + * overwritten by the common properties that the telemetry pipeline injects. + * + * Collected from: + * - src/vs/platform/telemetry/common/commonProperties.ts (resolveCommonProperties) + * - src/vs/workbench/services/telemetry/common/workbenchCommonProperties.ts + * - src/vs/workbench/services/telemetry/browser/workbenchCommonProperties.ts + */ +const commonTelemetryProperties = new Set([ + 'common.machineid', + 'common.sqmid', + 'common.devdeviceid', + 'sessionid', + 'commithash', + 'version', + 'common.releasedate', + 'common.platformversion', + 'common.platform', + 'common.nodeplatform', + 'common.nodearch', + 'common.product', + 'common.msftinternal', + 'timestamp', + 'common.timesincesessionstart', + 'common.sequence', + 'common.snap', + 'common.platformdetail', + 'common.version.shell', + 'common.version.renderer', + 'common.firstsessiondate', + 'common.lastsessiondate', + 'common.isnewsession', + 'common.remoteauthority', + 'common.cli', + 'common.useragent', + 'common.istouchdevice', +]); + +export default new class NoTelemetryCommonProperty implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noCommonProperty: 'Telemetry events must not contain the common property "{{name}}". Common properties are automatically added by the telemetry pipeline and will be dropped.', + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + /** + * Check whether any property key in an object expression is a reserved common telemetry property. + */ + function checkObjectForCommonProperties(node: ESTree.ObjectExpression) { + for (const prop of node.properties) { + if (prop.type === 'Property') { + let name: string | undefined; + if (prop.key.type === 'Identifier') { + name = prop.key.name; + } else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { + name = prop.key.value; + } + if (name && commonTelemetryProperties.has(name.toLowerCase())) { + context.report({ + node: prop.key, + messageId: 'noCommonProperty', + data: { name }, + }); + } + } + } + } + + return { + ['CallExpression[callee.property.type="Identifier"]'](node: ESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== 'MemberExpression') { + return; + } + const prop = callee.property; + if (prop.type !== 'Identifier' || !telemetryMethods.has(prop.name)) { + return; + } + // The data argument is the second argument for publicLog/publicLog2/publicLogError/publicLogError2 + const dataArg = node.arguments[1]; + if (dataArg && dataArg.type === 'ObjectExpression') { + checkObjectForCommonProperties(dataArg); + } + }, + }; + } +}; diff --git a/.eslint-plugin-local/code-translation-remind.ts b/.eslint-plugin-local/code-translation-remind.ts index 4203232116710..ed636ec0cb689 100644 --- a/.eslint-plugin-local/code-translation-remind.ts +++ b/.eslint-plugin-local/code-translation-remind.ts @@ -26,18 +26,19 @@ export default new class TranslationRemind implements eslint.Rule.RuleModule { private _checkImport(context: eslint.Rule.RuleContext, node: TSESTree.Node, path: string) { - if (path !== TranslationRemind.NLS_MODULE) { + if (path !== TranslationRemind.NLS_MODULE && !path.endsWith('/nls.js')) { return; } const currentFile = context.getFilename(); const matchService = currentFile.match(/vs\/workbench\/services\/\w+/); const matchPart = currentFile.match(/vs\/workbench\/contrib\/\w+/); - if (!matchService && !matchPart) { + const matchSessionsPart = currentFile.match(/vs\/sessions\/contrib\/\w+/); + if (!matchService && !matchPart && !matchSessionsPart) { return; } - const resource = matchService ? matchService[0] : matchPart![0]; + const resource = matchService ? matchService[0] : matchPart ? matchPart[0] : matchSessionsPart![0]; let resourceDefined = false; let json; @@ -47,9 +48,10 @@ export default new class TranslationRemind implements eslint.Rule.RuleModule { console.error('[translation-remind rule]: File with resources to pull from Transifex was not found. Aborting translation resource check for newly defined workbench part/service.'); return; } - const workbenchResources = JSON.parse(json).workbench; + const parsed = JSON.parse(json); + const resources = [...parsed.workbench, ...parsed.sessions]; - workbenchResources.forEach((existingResource: any) => { + resources.forEach((existingResource: any) => { if (existingResource.name === resource) { resourceDefined = true; return; diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 7aba51a470b27..3091e0050f047 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -26,6 +26,7 @@ src/vs/base/browser/ui/tree/** @joaomoreno @benibenj # Platform src/vs/platform/auxiliaryWindow/** @bpasero src/vs/platform/backup/** @bpasero +src/vs/platform/browserView/** @kycutler @jruales src/vs/platform/dialogs/** @bpasero src/vs/platform/editor/** @bpasero src/vs/platform/environment/** @bpasero @@ -40,8 +41,8 @@ src/vs/platform/secrets/** @TylerLeonhardt src/vs/platform/sharedProcess/** @bpasero src/vs/platform/state/** @bpasero src/vs/platform/storage/** @bpasero -src/vs/platform/terminal/electron-main/** @Tyriar -src/vs/platform/terminal/node/** @Tyriar +src/vs/platform/terminal/electron-main/** @anthonykim1 +src/vs/platform/terminal/node/** @anthonykim1 src/vs/platform/utilityProcess/** @bpasero src/vs/platform/window/** @bpasero src/vs/platform/windows/** @bpasero @@ -65,6 +66,7 @@ src/vs/code/** @bpasero @deepak1556 src/vs/workbench/services/activity/** @bpasero src/vs/workbench/services/authentication/** @TylerLeonhardt src/vs/workbench/services/auxiliaryWindow/** @bpasero +src/vs/workbench/services/browserView/** @kycutler @jruales src/vs/workbench/services/contextmenu/** @bpasero src/vs/workbench/services/dialogs/** @alexr00 @bpasero src/vs/workbench/services/editor/** @bpasero @@ -97,6 +99,7 @@ src/vs/workbench/electron-browser/** @bpasero # Workbench Contributions src/vs/workbench/contrib/authentication/** @TylerLeonhardt +src/vs/workbench/contrib/browserView/** @kycutler @jruales src/vs/workbench/contrib/files/** @bpasero src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens src/vs/workbench/contrib/localization/** @TylerLeonhardt diff --git a/.github/agents/data.md b/.github/agents/data.md index 605bd276ef9a3..37f83c638cb79 100644 --- a/.github/agents/data.md +++ b/.github/agents/data.md @@ -42,3 +42,7 @@ Your response should include: - Interpretation and analysis of the results - References to specific documentation files when applicable - Additional context or insights from the telemetry data + +# Troubleshooting + +If the connection to the Kusto cluster is timing out consistently, stop and ask the user to check whether they are connected to Azure VPN. diff --git a/.github/agents/sessions.md b/.github/agents/sessions.md new file mode 100644 index 0000000000000..1bd1d3986c399 --- /dev/null +++ b/.github/agents/sessions.md @@ -0,0 +1,15 @@ +--- +name: Sessions Window Developer +description: Specialist in developing the Agent Sessions Window +--- + +# Role and Objective + +You are a developer working on the 'sessions window'. Your goal is to make changes to the sessions window (`src/vs/sessions`), minimally editing outside of that directory. + +# Instructions + +1. **Always read the `sessions` skill first.** This is your primary source of truth for the sessions architecture. + - Invoke `skill: "sessions"`. +2. Focus your work on `src/vs/sessions/`. +3. Avoid making changes to core VS Code files (`src/vs/workbench/`, `src/vs/platform/`, etc.) unless absolutely necessary for the sessions window functionality. diff --git a/.github/classifier.json b/.github/classifier.json index 32b6880011395..39ebd9e38b222 100644 --- a/.github/classifier.json +++ b/.github/classifier.json @@ -16,7 +16,7 @@ "bracket-pair-guides": {"assign": ["hediet"]}, "breadcrumbs": {"assign": ["jrieken"]}, "callhierarchy": {"assign": ["jrieken"]}, - "chat-terminal": {"assign": ["Tyriar"]}, + "chat-terminal": {"assign": ["meganrogge"]}, "chat-terminal-output-monitor": {"assign": ["meganrogge"]}, "chrome-devtools": {"assign": ["deepak1556"]}, "cloud-changes": {"assign": ["joyceerhl"]}, @@ -228,18 +228,18 @@ "terminal-env-collection": {"assign": ["anthonykim1"]}, "terminal-external": {"assign": ["anthonykim1"]}, "terminal-find": {"assign": ["anthonykim1"]}, - "terminal-inline-chat": {"assign": ["Tyriar", "meganrogge"]}, - "terminal-input": {"assign": ["Tyriar"]}, + "terminal-inline-chat": {"assign": ["meganrogge"]}, + "terminal-input": {"assign": ["anthonykim1"]}, "terminal-layout": {"assign": ["anthonykim1"]}, - "terminal-ligatures": {"assign": ["Tyriar"]}, + "terminal-ligatures": {"assign": ["anthonykim1"]}, "terminal-links": {"assign": ["anthonykim1"]}, "terminal-local-echo": {"assign": ["anthonykim1"]}, - "terminal-parser": {"assign": ["Tyriar"]}, - "terminal-persistence": {"assign": ["Tyriar"]}, + "terminal-parser": {"assign": ["anthonykim1"]}, + "terminal-persistence": {"assign": ["anthonykim1"]}, "terminal-process": {"assign": ["anthonykim1"]}, "terminal-profiles": {"assign": ["meganrogge"]}, "terminal-quick-fix": {"assign": ["meganrogge"]}, - "terminal-rendering": {"assign": ["Tyriar"]}, + "terminal-rendering": {"assign": ["anthonykim1"]}, "terminal-shell-bash": {"assign": ["anthonykim1"]}, "terminal-shell-cmd": {"assign": ["anthonykim1"]}, "terminal-shell-fish": {"assign": ["anthonykim1"]}, @@ -283,7 +283,7 @@ "workbench-auxwindow": {"assign": ["bpasero"]}, "workbench-banner": {"assign": ["lszomoru", "sbatten"]}, "workbench-cli": {"assign": ["bpasero"]}, - "workbench-diagnostics": {"assign": ["Tyriar"]}, + "workbench-diagnostics": {"assign": ["rebornix"]}, "workbench-dnd": {"assign": ["bpasero"]}, "workbench-editor-grid": {"assign": ["benibenj"]}, "workbench-editor-groups": {"assign": ["bpasero"]}, @@ -293,7 +293,7 @@ "workbench-fonts": {"assign": []}, "workbench-history": {"assign": ["bpasero"]}, "workbench-hot-exit": {"assign": ["bpasero"]}, - "workbench-hover": {"assign": ["Tyriar", "benibenj"]}, + "workbench-hover": {"assign": ["benibenj"]}, "workbench-launch": {"assign": []}, "workbench-link": {"assign": []}, "workbench-multiroot": {"assign": ["bpasero"]}, diff --git a/.github/commands.json b/.github/commands.json index 978a0960eabf6..c52e21eeb8dec 100644 --- a/.github/commands.json +++ b/.github/commands.json @@ -631,7 +631,7 @@ "addLabel": "capi", "removeLabel": "~capi", "assign": [ - "samvantran", + "rheapatel", "sharonlo" ], "comment": "Thank you for creating this issue! Please provide one or more `requestIds` to help the platform team investigate. You can follow instructions [found here](https://github.com/microsoft/vscode/wiki/Copilot-Issues#language-model-requests-and-responses) to locate the `requestId` value.\n\nHappy Coding!" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 62d002fc4564b..54502973051aa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,7 @@ Visual Studio Code is built with a layered architecture using TypeScript, web AP - `workbench/api/` - Extension host and VS Code API implementation - `src/vs/code/` - Electron main process specific implementation - `src/vs/server/` - Server specific implementation +- `src/vs/sessions/` - Agent sessions window, a dedicated workbench layer for agentic workflows (sits alongside `vs/workbench`, may import from it but not vice versa) The core architecture follows these principles: - **Layered architecture** - from `base`, `platform`, `editor`, to `workbench` @@ -49,15 +50,16 @@ Each extension follows the standard VS Code extension structure with `package.js ## Validating TypeScript changes -MANDATORY: Always check the `VS Code - Build` watch task output via #runTasks/getTaskOutput for compilation errors before running ANY script or declaring work complete, then fix all compilation errors before moving forward. +MANDATORY: Always check for compilation errors before running any tests or validation scripts, or declaring work complete, then fix all compilation errors before moving forward. - NEVER run tests if there are compilation errors -- NEVER use `npm run compile` to compile TypeScript files but call #runTasks/getTaskOutput instead +- NEVER use `npm run compile` to compile TypeScript files ### TypeScript compilation steps -- Monitor the `VS Code - Build` task outputs for real-time compilation errors as you make changes -- This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions -- Start the task if it's not already running in the background +- If the `#runTasks/getTaskOutput` tool is available, check the `VS Code - Build` watch task output for compilation errors. This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions. Start the task if it's not already running in the background. +- If the tool is not available (e.g. in CLI environments) and you only changed code under `src/`, run `npm run compile-check-ts-native` after making changes to type-check the main VS Code sources (it validates `./src/tsconfig.json`). +- If you changed built-in extensions under `extensions/` and the tool is not available, run the corresponding gulp task `npm run gulp compile-extensions` instead so that TypeScript errors in extensions are also reported. +- For TypeScript changes in the `build` folder, you can simply run `npm run typecheck` in the `build` folder. ### TypeScript validation steps - Use the run test tool if you need to run tests. If that tool is not available, then you can use `scripts/test.sh` (or `scripts\test.bat` on Windows) for unit tests (add `--grep ` to filter tests) or `scripts/test-integration.sh` (or `scripts\test-integration.bat` on Windows) for integration tests (integration tests end with .integrationTest.ts or are in /extensions/). @@ -134,6 +136,7 @@ function f(x: number, y: string): void { } - Prefer regex capture groups with names over numbered capture groups. - If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task - Never duplicate imports. Always reuse existing imports if they are present. +- When removing an import, do not leave behind blank lines where the import was. Ensure the surrounding code remains compact. - Do not use `any` or `unknown` as the type for variables, parameters, or return values unless absolutely necessary. If they need type annotations, they should have proper types or interfaces defined. - When adding file watching, prefer correlated file watchers (via fileService.createWatcher) to shared ones. - When adding tooltips to UI elements, prefer the use of IHoverService service. @@ -142,6 +145,7 @@ function f(x: number, y: string): void { } - You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. - Use `IEditorService` to open editors instead of `IEditorGroupsService.activeGroup.openEditor` to ensure that the editor opening logic is properly followed and to avoid bypassing important features such as `revealIfOpened` or `preserveFocus`. - Avoid using `bind()`, `call()` and `apply()` solely to control `this` or partially apply arguments; prefer arrow functions or closures to capture the necessary context, and use these methods only when required by an API or interoperability. +- Avoid using events to drive control flow between components. Instead, prefer direct method calls or service interactions to ensure clearer dependencies and easier traceability of logic. Events should be reserved for broadcasting state changes or notifications rather than orchestrating behavior across components. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json new file mode 100644 index 0000000000000..4457634963e9e --- /dev/null +++ b/.github/hooks/hooks.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "" + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": "" + } + ], + "agentStop": [ + { + "type": "command", + "bash": "" + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "bash": "" + } + ], + "preToolUse": [ + { + "type": "command", + "bash": "" + } + ], + "postToolUse": [ + { + "type": "command", + "bash": "" + } + ] + } +} diff --git a/.github/instructions/agentHostTesting.instructions.md b/.github/instructions/agentHostTesting.instructions.md new file mode 100644 index 0000000000000..7ffbbe4ff6ec5 --- /dev/null +++ b/.github/instructions/agentHostTesting.instructions.md @@ -0,0 +1,27 @@ +--- +description: Architecture documentation for VS Code AI Customization view. Use when working in `src/vs/workbench/contrib/chat/browser/aiCustomization` +applyTo: 'src/vs/platform/agentHost/**' +--- + +# Agent Host + +The agent host communicates via the Agent Host Protocol. The specification for this lives in a directory `../agent-host-protocol` as a sibling of the VS Code directory. + +If this directory doesn't exist, you should use the "ask questions" tool to ask the user if they want to clone `git@github.com:microsoft/agent-host-protocol.git` to that directory. After doing so, you should also prompt the user to add `file:////plugins/copilot-plugin` as a plugin in their `chat.pluginLocations` settings. + +## Overall Protocol + +The sessions process is a portable, standalone server that multiple clients can connect to. Clients see a synchronized view of sessions and can send commands that are reflected back as state-changing actions. The protocol is designed around four requirements: + +1. **Synchronized multi-client state** — an immutable, redux-like state tree mutated exclusively by actions flowing through pure reducers. While there is the option to implement functionality via imperative commands, we ALWAYS prefer to model features as pure state and actions. +2. **Lazy loading** — clients subscribe to state by URI and load data on demand. The session list is fetched imperatively. Large content (images, long tool outputs) is stored by reference and fetched separately. +3. **Write-ahead with reconciliation** — clients optimistically apply their own actions locally, then reconcile when the server echoes them back alongside any concurrent actions from other clients or the server itself. +4. **Forward-compatible versioning** — newer clients can connect to older servers. A single protocol version number maps to a capabilities object; clients check capabilities before using features. + +See the agent host protocol documentation for more details. + +## End to End Testing + +You can run `node ./scripts/code-agent-host.js` to start an agent host. If you pass `--enable-mock-agent`, then the `ScriptedMockAgent` will be used. + +By default this will listen on `ws://127.0.0.1:8081`. You can then use the `ahp-websocket` client, when available, to connect to and communicate with it. diff --git a/.github/instructions/kusto.instructions.md b/.github/instructions/kusto.instructions.md index 2c77e92555d6c..ac247c5772415 100644 --- a/.github/instructions/kusto.instructions.md +++ b/.github/instructions/kusto.instructions.md @@ -6,7 +6,7 @@ description: Kusto exploration and telemetry analysis instructions When performing Kusto queries, telemetry analysis, or data exploration tasks for VS Code, consult the comprehensive Kusto instructions located at: -**[kusto-vscode-instructions.md](../../../vscode-internalbacklog/instructions/kusto/kusto-vscode-instructions.md)** +**[kusto-vscode-instructions.md](../../../vscode-tools/.github/skills/kusto-telemetry/kusto-vscode.instructions.md)** These instructions contain valuable information about: - Available Kusto clusters and databases for VS Code telemetry @@ -16,4 +16,4 @@ These instructions contain valuable information about: Reading these instructions before writing Kusto queries will help you write more accurate and efficient queries, avoid common pitfalls, and leverage existing knowledge about VS Code's telemetry infrastructure. -(Make sure to have the main branch of vscode-internalbacklog up to date in case there are problems). +(Make sure to have the main branch of vscode-tools up to date in case there are problems and the repository cloned from https://github.com/microsoft/vscode-tools). diff --git a/.github/instructions/oss.instructions.md b/.github/instructions/oss.instructions.md new file mode 100644 index 0000000000000..2e73cdbbbc20b --- /dev/null +++ b/.github/instructions/oss.instructions.md @@ -0,0 +1,34 @@ +--- +applyTo: '{ThirdPartyNotices.txt,cli/ThirdPartyNotices.txt,cglicenses.json,cgmanifest.json}' +--- + +# OSS License Review + +When reviewing changes to these files, verify: + +## ThirdPartyNotices.txt + +- Every new entry has a license type header (e.g., "MIT License", "Apache License 2.0") +- License text is present and non-empty for every entry +- License text matches the declared license type (e.g., MIT-declared entry actually contains MIT license text, not Apache) +- Removed entries are cleanly removed (no leftover fragments) +- Entries are sorted alphabetically by package name + +## cglicenses.json + +- New overrides have a justification comment +- No obviously stale entries for packages no longer in the dependency tree + +## cgmanifest.json + +- Package versions match what's actually installed +- Repository URLs are valid and point to real source repositories +- Newly added license identifiers should use SPDX format where possible +- License identifiers match the corresponding ThirdPartyNotices.txt entries + +## Red Flags + +- Any **newly added** copyleft license (GPL, LGPL, AGPL) — flag immediately (existing copyleft entries like ffmpeg are pre-approved) +- Any "UNKNOWN" or placeholder license text +- License text that appears truncated or corrupted +- A package declared as MIT but with Apache/BSD/other license text (or vice versa) diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md index 1cdc8fa90b4d8..3781f160e76a5 100644 --- a/.github/prompts/fix-error.prompt.md +++ b/.github/prompts/fix-error.prompt.md @@ -15,3 +15,40 @@ Follow the `fix-errors` skill guidelines to fix this error. Key principles: 4. **If the producer is identifiable**, fix it directly. After making changes, check for compilation errors via the build task and run relevant unit tests. + +## Submitting the Fix + +After the fix is validated (compilation clean, tests pass): + +1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). +2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. +3. **Push**: `git push -u origin `. +4. **Create a draft PR** with a description that includes these sections: + - **Summary**: A concise description of what was changed and why. + - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. + - **Trigger scenarios**: What user actions or system conditions cause this error to surface. + - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: + ```` + ```mermaid + sequenceDiagram + participant A as CallerComponent + participant B as MiddleLayer + participant C as LowLevelUtil + A->>B: someOperation(data) + B->>C: validate(data) + C-->>C: data is invalid + C->>B: throws "error message" + B->>A: unhandled error propagates + ``` + ```` + - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). + - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. +5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. + - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. + - If there are review comments, evaluate each one: + - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). + - If not applicable, leave a reply explaining why. + - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. + - **Re-run tests** after addressing review comments to confirm nothing regressed. + - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. +6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. diff --git a/.github/skills/accessibility/SKILL.md b/.github/skills/accessibility/SKILL.md index 1d141f0c09275..591e85ff8123c 100644 --- a/.github/skills/accessibility/SKILL.md +++ b/.github/skills/accessibility/SKILL.md @@ -1,8 +1,22 @@ --- name: accessibility -description: Accessibility guidelines for VS Code features — covers accessibility help dialogs, accessible views, verbosity settings, accessibility signals, ARIA alerts/status announcements, keyboard navigation, and ARIA labels/roles. Applies to both new interactive UI surfaces and updates to existing features. Use when creating new UI or updating existing UI features. +description: Primary accessibility skill for VS Code. REQUIRED for new feature and contribution work, and also applies to updates of existing UI. Covers accessibility help dialogs, accessible views, verbosity settings, signals, ARIA announcements, keyboard navigation, and ARIA labels/roles. --- +## When to Use This Skill + +Use this skill for any VS Code feature work that introduces or changes interactive UI. +Use this skill by default for new features and contributions, including when the request does not explicitly mention accessibility. + +Trigger examples: +- "add a new feature" +- "implement a new panel/view/widget" +- "add a new command or workflow" +- "new contribution in workbench/editor/extensions" +- "update existing UI interactions" + +Do not skip this skill just because accessibility is not named in the prompt. + When adding a **new interactive UI surface** to VS Code — a panel, view, widget, editor overlay, dialog, or any rich focusable component the user interacts with — you **must** provide three accessibility components (if they do not already exist for the feature): 1. **An Accessibility Help Dialog** — opened via the accessibility help keybinding when the feature has focus. @@ -47,10 +61,7 @@ An accessibility help dialog tells the user what the feature does, which keyboar The simplest approach is to return an `AccessibleContentProvider` directly from `getProvider()`. This is the most common pattern in the codebase (used by chat, inline chat, quick chat, etc.): ```ts -import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '…/accessibleView.js'; -import { IAccessibleViewImplementation } from '…/accessibleViewRegistry.js'; -import { AccessibilityVerbositySettingId } from '…/accessibilityConfiguration.js'; -import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { AccessibilityVerbositySettingId } from '../../../../platform/accessibility/common/accessibilityConfiguration.js'; diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md new file mode 100644 index 0000000000000..64119b056bad3 --- /dev/null +++ b/.github/skills/add-policy/SKILL.md @@ -0,0 +1,139 @@ +--- +name: add-policy +description: Use when adding, modifying, or reviewing VS Code configuration policies. Covers the full policy lifecycle from registration to export to platform-specific artifacts. Run on ANY change that adds a `policy:` field to a configuration property. +--- + +# Adding a Configuration Policy + +Policies allow enterprise administrators to lock configuration settings via OS-level mechanisms (Windows Group Policy, macOS managed preferences, Linux config files) or via Copilot account-level policy data. This skill covers the complete procedure. + +## When to Use + +- Adding a new `policy:` field to any configuration property +- Modifying an existing policy (rename, category change, etc.) +- Reviewing a PR that touches policy registration +- Adding account-based policy support via `IPolicyData` + +## Architecture Overview + +### Policy Sources (layered, last writer wins) + +| Source | Implementation | How it reads policies | +|--------|---------------|----------------------| +| **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | +| **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function | +| **Multiplex** | `MultiplexPolicyService` | Combines OS-level + account policy services; used in desktop main | + +### Key Files + +| File | Purpose | +|------|---------| +| `src/vs/base/common/policy.ts` | `PolicyCategory` enum, `IPolicy` interface | +| `src/vs/platform/policy/common/policy.ts` | `IPolicyService`, `AbstractPolicyService`, `PolicyDefinition` | +| `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation | +| `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | +| `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | +| `src/vs/base/common/defaultAccount.ts` | `IPolicyData` interface for account-level policy fields | +| `build/lib/policies/policyData.jsonc` | Auto-generated policy catalog (DO NOT edit manually) | +| `build/lib/policies/policyGenerator.ts` | Generates ADMX/ADML (Windows), plist (macOS), JSON (Linux) | +| `build/lib/test/policyConversion.test.ts` | Tests for policy artifact generation | + +## Procedure + +### Step 1 — Add the `policy` field to the configuration property + +Find the configuration registration (typically in a `*.contribution.ts` file) and add a `policy` object to the property schema. + +**Required fields:** + +**Determining `minimumVersion`:** Always read `version` from the root `package.json` and use the `major.minor` portion. For example, if `package.json` has `"version": "1.112.0"`, use `minimumVersion: '1.112'`. Never hardcode an old version like `'1.99'`. + +```typescript +policy: { + name: 'MyPolicyName', // PascalCase, unique across all policies + category: PolicyCategory.InteractiveSession, // From PolicyCategory enum + minimumVersion: '1.112', // Use major.minor from package.json version + localization: { + description: { + key: 'my.config.key', // NLS key for the description + value: nls.localize('my.config.key', "Human-readable description."), + } + } +} +``` + +**Optional: `value` function for account-based policy:** + +If this policy should also be controllable via Copilot account policy data (from `IPolicyData`), add a `value` function: + +```typescript +policy: { + name: 'MyPolicyName', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.112', // Use major.minor from package.json version + value: (policyData) => policyData.my_field === false ? false : undefined, + localization: { /* ... */ } +} +``` + +The `value` function receives `IPolicyData` (from `src/vs/base/common/defaultAccount.ts`) and should: +- Return a concrete value to **override** the user's setting +- Return `undefined` to **not apply** any account-level override (falls through to OS policy or user setting) + +If you need a new field on `IPolicyData`, add it to the interface in `src/vs/base/common/defaultAccount.ts`. + +**Optional: `enumDescriptions` for enum/string policies:** + +```typescript +localization: { + description: { key: '...', value: nls.localize('...', "...") }, + enumDescriptions: [ + { key: 'opt.none', value: nls.localize('opt.none', "No access.") }, + { key: 'opt.all', value: nls.localize('opt.all', "Full access.") }, + ] +} +``` + +### Step 2 — Ensure `PolicyCategory` is imported + +```typescript +import { PolicyCategory } from '../../../../base/common/policy.js'; +``` + +Existing categories in the `PolicyCategory` enum: +- `Extensions` +- `IntegratedTerminal` +- `InteractiveSession` (used for all chat/Copilot policies) +- `Telemetry` +- `Update` + +If you need a new category, add it to `PolicyCategory` in `src/vs/base/common/policy.ts` and add corresponding `PolicyCategoryData` localization. + +### Step 3 — Validate TypeScript compilation + +Check the `VS Code - Build` watch task output, or run: + +```bash +npm run compile-check-ts-native +``` + +### Step 4 — Export the policy data + +Regenerate the auto-generated policy catalog: + +```bash +npm run transpile-client && ./scripts/code.sh --export-policy-data +``` + +This updates `build/lib/policies/policyData.jsonc`. **Never edit this file manually.** Verify your new policy appears in the output. You will need code review from a codeowner to merge the change to main. + + +## Policy for extension-provided settings + +For an extension author to provide policies for their extension's settings, a change must be made in `vscode-distro` to the `product.json`. + +## Examples + +Search the codebase for `policy:` to find all the examples of different policy configurations. diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md index a76794d9c7d8a..af4f03a3f6066 100644 --- a/.github/skills/agent-sessions-layout/SKILL.md +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -45,7 +45,7 @@ When proposing or implementing changes, follow these rules from the spec: 4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar 5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors 6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites -7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`, `ProjectBarPart`), not the standard workbench parts 8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state 9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants @@ -53,20 +53,24 @@ When proposing or implementing changes, follow these rules from the spec: | File | Purpose | |------|---------| -| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/LAYOUT.md` | Authoritative layout specification | | `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | | `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | | `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | | `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | -| `sessions/browser/style.css` | Layout-specific styles | -| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/media/style.css` | Layout-specific styles | +| `sessions/browser/parts/parts.ts` | `AgenticParts` enum | | `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | -| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer and macOS traffic light spacer) | | `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | -| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/browser/parts/auxiliaryBarPart.ts` | Auxiliary Bar part (with run script dropdown) | +| `sessions/browser/parts/panelPart.ts` | Panel part | +| `sessions/browser/parts/projectBarPart.ts` | Project Bar part (folder entries, icon customization) | +| `sessions/contrib/configuration/browser/configuration.contribution.ts` | Sets `workbench.editor.useModal` to `'all'` for modal editor overlay | | `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | -| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script split button for titlebar | | `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | +| `sessions/electron-browser/parts/titlebarPart.ts` | Desktop (Electron) titlebar part | ## 5. Testing Changes diff --git a/.github/skills/azure-pipelines/SKILL.md b/.github/skills/azure-pipelines/SKILL.md index b7b2e164e038d..8903496983c88 100644 --- a/.github/skills/azure-pipelines/SKILL.md +++ b/.github/skills/azure-pipelines/SKILL.md @@ -66,21 +66,24 @@ Use the [queue command](./azure-pipeline.ts) to queue a validation build: ```bash # Queue a build on the current branch -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +node .github/skills/azure-pipelines/azure-pipeline.ts queue # Queue with a specific source branch -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --branch my-feature-branch +node .github/skills/azure-pipelines/azure-pipeline.ts queue --branch my-feature-branch -# Queue with custom variables (e.g., to skip certain stages) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "SKIP_TESTS=true" +# Queue with custom parameters +node .github/skills/azure-pipelines/azure-pipeline.ts queue --parameter "VSCODE_BUILD_WEB=false" --parameter "VSCODE_PUBLISH=false" + +# Parameter value with spaces +node .github/skills/azure-pipelines/azure-pipeline.ts queue --parameter "VSCODE_BUILD_TYPE=Product Build" ``` > **Important**: Before queueing a new build, cancel any previous builds on the same branch that you no longer need. This frees up build agents and reduces resource waste: > ```bash > # Find the build ID from status, then cancel it -> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status -> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id -> node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue +> node .github/skills/azure-pipelines/azure-pipeline.ts status +> node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id +> node .github/skills/azure-pipelines/azure-pipeline.ts queue > ``` ### Script Options @@ -89,9 +92,43 @@ node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts |--------|-------------| | `--branch ` | Source branch to build (default: current git branch) | | `--definition ` | Pipeline definition ID (default: 111) | -| `--variables ` | Pipeline variables in `KEY=value` format, space-separated | +| `--parameter ` | Pipeline parameter in `KEY=value` format (repeatable); **use this when the value contains spaces** | +| `--parameters ` | Space-separated parameters in `KEY=value KEY2=value2` format; values **must not** contain spaces | | `--dry-run` | Print the command without executing | +### Product Build Queue Parameters (`build/azure-pipelines/product-build.yml`) + +| Name | Type | Default | Allowed Values | Description | +|------|------|---------|----------------|-------------| +| `VSCODE_QUALITY` | string | `insider` | `exploration`, `insider`, `stable` | Build quality channel | +| `VSCODE_BUILD_TYPE` | string | `Product Build` | `Product`, `CI` | Build mode for Product vs CI | +| `NPM_REGISTRY` | string | `https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/` | any URL | Custom npm registry | +| `CARGO_REGISTRY` | string | `sparse+https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/Cargo/index/` | any URL | Custom Cargo registry | +| `VSCODE_BUILD_WIN32` | boolean | `true` | `true`, `false` | Build Windows x64 | +| `VSCODE_BUILD_WIN32_ARM64` | boolean | `true` | `true`, `false` | Build Windows arm64 | +| `VSCODE_BUILD_LINUX` | boolean | `true` | `true`, `false` | Build Linux x64 | +| `VSCODE_BUILD_LINUX_SNAP` | boolean | `true` | `true`, `false` | Build Linux x64 Snap | +| `VSCODE_BUILD_LINUX_ARM64` | boolean | `true` | `true`, `false` | Build Linux arm64 | +| `VSCODE_BUILD_LINUX_ARMHF` | boolean | `true` | `true`, `false` | Build Linux armhf | +| `VSCODE_BUILD_ALPINE` | boolean | `true` | `true`, `false` | Build Alpine x64 | +| `VSCODE_BUILD_ALPINE_ARM64` | boolean | `true` | `true`, `false` | Build Alpine arm64 | +| `VSCODE_BUILD_MACOS` | boolean | `true` | `true`, `false` | Build macOS x64 | +| `VSCODE_BUILD_MACOS_ARM64` | boolean | `true` | `true`, `false` | Build macOS arm64 | +| `VSCODE_BUILD_MACOS_UNIVERSAL` | boolean | `true` | `true`, `false` | Build macOS universal (requires both macOS arches) | +| `VSCODE_BUILD_WEB` | boolean | `true` | `true`, `false` | Build Web artifacts | +| `VSCODE_PUBLISH` | boolean | `true` | `true`, `false` | Publish to builds.code.visualstudio.com | +| `VSCODE_RELEASE` | boolean | `false` | `true`, `false` | Trigger release flow if successful | +| `VSCODE_STEP_ON_IT` | boolean | `false` | `true`, `false` | Skip tests | + +Example: run a quick CI-oriented validation with minimal publish/release side effects: + +```bash +node .github/skills/azure-pipelines/azure-pipeline.ts queue \ + --parameter "VSCODE_BUILD_TYPE=CI Build" \ + --parameter "VSCODE_PUBLISH=false" \ + --parameter "VSCODE_RELEASE=false" +``` + --- ## Checking Build Status @@ -99,17 +136,17 @@ node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts Use the [status command](./azure-pipeline.ts) to monitor a running build: ```bash -# Get status of the most recent build on your branch -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +# Get status of the most recent builds +node .github/skills/azure-pipelines/azure-pipeline.ts status # Get overview of a specific build by ID -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 # Watch build status (refreshes every 30 seconds) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +node .github/skills/azure-pipelines/azure-pipeline.ts status --watch # Watch with custom interval (60 seconds) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch 60 +node .github/skills/azure-pipelines/azure-pipeline.ts status --watch 60 ``` ### Script Options @@ -133,10 +170,10 @@ Use the [cancel command](./azure-pipeline.ts) to stop a running build: ```bash # Cancel a build by ID (use status command to find IDs) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 # Dry run (show what would be cancelled) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run ``` ### Script Options @@ -149,6 +186,44 @@ node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts --- +## Testing Pipeline Changes + +When the user asks to **test changes in an Azure Pipelines build**, follow this workflow: + +1. **Queue a new build** on the current branch +2. **Poll for completion** by periodically checking the build status until it finishes + +### Polling for Build Completion + +Use a shell loop with `sleep` to poll the build status. The `sleep` command works on all major operating systems: + +```bash +# Queue the build and note the build ID from output (e.g., 123456) +node .github/skills/azure-pipelines/azure-pipeline.ts queue + +# Poll every 60 seconds until complete (works on macOS, Linux, and Windows with Git Bash/WSL) +# Replace with the actual build ID from the queue command +while true; do + node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id --json 2>/dev/null | grep -q '"status": "completed"' && break + sleep 60 +done + +# Check final result +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id +``` + +Alternatively, use the built-in `--watch` flag which handles polling automatically: + +```bash +node .github/skills/azure-pipelines/azure-pipeline.ts queue +# Use the build ID returned by the queue command +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id --watch +``` + +> **Note**: The `--watch` flag polls every 30 seconds by default. Use `--watch 60` for a 60-second interval to reduce API calls. + +--- + ## Common Workflows ### 1. Quick Pipeline Validation @@ -159,45 +234,50 @@ git add -A && git commit -m "test: pipeline changes" git push origin HEAD # Check for any previous builds on this branch and cancel if needed -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id # if there's an active build +node .github/skills/azure-pipelines/azure-pipeline.ts status +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id # if there's an active build # Queue and watch the new build -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +node .github/skills/azure-pipelines/azure-pipeline.ts queue +node .github/skills/azure-pipelines/azure-pipeline.ts status --watch ``` ### 2. Investigate a Build ```bash # Get overview of a build (shows stages, artifacts, and log IDs) -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 # Download a specific log for deeper inspection -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-log 5 +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-log 5 # Download an artifact -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-artifact unsigned_vscode_cli_win32_x64_cli +node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id 123456 --download-artifact unsigned_vscode_cli_win32_x64_cli ``` -### 3. Test with Modified Variables +### 3. Test with Modified Parameters ```bash -# Skip expensive stages during validation -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue --variables "VSCODE_BUILD_SKIP_INTEGRATION_TESTS=true" +# Customize build matrix for quicker validation +node .github/skills/azure-pipelines/azure-pipeline.ts queue \ + --parameter "VSCODE_BUILD_TYPE=CI Build" \ + --parameter "VSCODE_BUILD_WEB=false" \ + --parameter "VSCODE_BUILD_ALPINE=false" \ + --parameter "VSCODE_BUILD_ALPINE_ARM64=false" \ + --parameter "VSCODE_PUBLISH=false" ``` ### 4. Cancel a Running Build ```bash # First, find the build ID -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status +node .github/skills/azure-pipelines/azure-pipeline.ts status # Cancel a specific build by ID -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 # Dry run to see what would be cancelled -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id 123456 --dry-run ``` ### 5. Iterate on Pipeline Changes @@ -210,12 +290,12 @@ git add -A && git commit --amend --no-edit git push --force-with-lease origin HEAD # Find the outdated build ID and cancel it -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id +node .github/skills/azure-pipelines/azure-pipeline.ts status +node .github/skills/azure-pipelines/azure-pipeline.ts cancel --build-id # Queue a fresh build and monitor -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue -node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --watch +node .github/skills/azure-pipelines/azure-pipeline.ts queue +node .github/skills/azure-pipelines/azure-pipeline.ts status --watch ``` --- diff --git a/.github/skills/azure-pipelines/azure-pipeline.ts b/.github/skills/azure-pipelines/azure-pipeline.ts index 7fad554050bb3..3032f01c6fde7 100644 --- a/.github/skills/azure-pipelines/azure-pipeline.ts +++ b/.github/skills/azure-pipelines/azure-pipeline.ts @@ -9,7 +9,7 @@ * A unified command-line tool for managing Azure Pipeline builds. * * Usage: - * node --experimental-strip-types azure-pipeline.ts [options] + * node azure-pipeline.ts [options] * * Commands: * queue - Queue a new pipeline build @@ -38,8 +38,8 @@ const NUMERIC_ID_PATTERN = /^\d+$/; const MAX_ID_LENGTH = 15; const BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/; const MAX_BRANCH_LENGTH = 256; -const VARIABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*=[a-zA-Z0-9_\-./: ]*$/; -const MAX_VARIABLE_LENGTH = 256; +const PARAMETER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*=[a-zA-Z0-9_\-./: +]*$/; +const MAX_PARAMETER_LENGTH = 256; const ARTIFACT_NAME_PATTERN = /^[a-zA-Z0-9_\-.]+$/; const MAX_ARTIFACT_NAME_LENGTH = 256; const MIN_WATCH_INTERVAL = 5; @@ -88,7 +88,7 @@ interface Artifact { interface QueueArgs { branch: string; definitionId: string; - variables: string; + parameters: string[]; dryRun: boolean; help: boolean; } @@ -159,19 +159,18 @@ function validateBranch(value: string): void { } } -function validateVariables(value: string): void { - if (!value) { +function validateParameters(values: string[]): void { + if (!values.length) { return; } - const vars = value.split(' ').filter(v => v.length > 0); - for (const v of vars) { - if (v.length > MAX_VARIABLE_LENGTH) { - console.error(colors.red(`Error: Variable '${v.substring(0, 20)}...' is too long (max ${MAX_VARIABLE_LENGTH} characters)`)); + for (const parameter of values) { + if (parameter.length > MAX_PARAMETER_LENGTH) { + console.error(colors.red(`Error: Parameter '${parameter.substring(0, 20)}...' is too long (max ${MAX_PARAMETER_LENGTH} characters)`)); process.exit(1); } - if (!VARIABLE_PATTERN.test(v)) { - console.error(colors.red(`Error: Invalid variable format '${v}'`)); - console.log('Expected format: KEY=value (alphanumeric, underscores, hyphens, dots, slashes, colons, spaces in value)'); + if (!PARAMETER_PATTERN.test(parameter)) { + console.error(colors.red(`Error: Invalid parameter format '${parameter}'`)); + console.log('Expected format: KEY=value (alphanumeric, underscores, hyphens, dots, slashes, colons, plus signs, spaces in value)'); process.exit(1); } } @@ -557,7 +556,11 @@ class AzureDevOpsClient { protected runAzCommand(args: string[]): Promise { return new Promise((resolve, reject) => { - const proc = spawn('az', args, { shell: true }); + // Use shell: false so that argument values with spaces are passed verbatim + // to the process without shell word-splitting. On Windows, az is a .cmd + // file and cannot be executed directly, so we must use az.cmd. + const azBin = process.platform === 'win32' ? 'az.cmd' : 'az'; + const proc = spawn(azBin, args, { shell: false }); let stdout = ''; let stderr = ''; @@ -612,7 +615,7 @@ class AzureDevOpsClient { return JSON.parse(result); } - async queueBuild(definitionId: string, branch: string, variables?: string): Promise { + async queueBuild(definitionId: string, branch: string, parameters: string[] = []): Promise { const args = [ 'pipelines', 'run', '--organization', this.organization, @@ -621,8 +624,8 @@ class AzureDevOpsClient { '--branch', branch, ]; - if (variables) { - args.push('--variables', ...variables.split(' ')); + if (parameters.length > 0) { + args.push('--parameters', ...parameters); } args.push('--output', 'json'); @@ -771,7 +774,7 @@ class AzureDevOpsClient { // ============================================================================ function printQueueUsage(): void { - const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'; + const scriptName = 'node .github/skills/azure-pipelines/azure-pipeline.ts queue'; console.log(`Usage: ${scriptName} [options]`); console.log(''); console.log('Queue an Azure DevOps pipeline build for VS Code.'); @@ -779,21 +782,23 @@ function printQueueUsage(): void { console.log('Options:'); console.log(' --branch Source branch to build (default: current git branch)'); console.log(' --definition Pipeline definition ID (default: 111)'); - console.log(' --variables Pipeline variables in "KEY=value KEY2=value2" format'); + console.log(' --parameter Pipeline parameter in "KEY=value" format (repeatable); use this for values with spaces'); + console.log(' --parameters Space-separated parameter list in "KEY=value KEY2=value2" format (values must not contain spaces)'); console.log(' --dry-run Print the command without executing'); console.log(' --help Show this help message'); console.log(''); console.log('Examples:'); console.log(` ${scriptName} # Queue build on current branch`); console.log(` ${scriptName} --branch my-feature # Queue build on specific branch`); - console.log(` ${scriptName} --variables "SKIP_TESTS=true" # Queue with custom variables`); + console.log(` ${scriptName} --parameter "VSCODE_BUILD_WEB=false" --parameter "VSCODE_PUBLISH=false"`); + console.log(` ${scriptName} --parameter "VSCODE_BUILD_TYPE=Product Build" # Parameter values with spaces`); } function parseQueueArgs(args: string[]): QueueArgs { const result: QueueArgs = { branch: '', definitionId: DEFAULT_DEFINITION_ID, - variables: '', + parameters: [], dryRun: false, help: false, }; @@ -807,8 +812,15 @@ function parseQueueArgs(args: string[]): QueueArgs { case '--definition': result.definitionId = args[++i] || DEFAULT_DEFINITION_ID; break; - case '--variables': - result.variables = args[++i] || ''; + case '--parameter': { + const parameter = args[++i] || ''; + if (parameter) { + result.parameters.push(parameter); + } + break; + } + case '--parameters': + result.parameters.push(...(args[++i] || '').split(' ').filter(v => v.length > 0)); break; case '--dry-run': result.dryRun = true; @@ -829,7 +841,7 @@ function parseQueueArgs(args: string[]): QueueArgs { function validateQueueArgs(args: QueueArgs): void { validateNumericId(args.definitionId, '--definition'); validateBranch(args.branch); - validateVariables(args.variables); + validateParameters(args.parameters); } async function runQueueCommand(args: string[]): Promise { @@ -860,8 +872,8 @@ async function runQueueCommand(args: string[]): Promise { console.log(`Project: ${colors.green(PROJECT)}`); console.log(`Definition: ${colors.green(parsedArgs.definitionId)}`); console.log(`Branch: ${colors.green(branch)}`); - if (parsedArgs.variables) { - console.log(`Variables: ${colors.green(parsedArgs.variables)}`); + if (parsedArgs.parameters.length > 0) { + console.log(`Parameters: ${colors.green(parsedArgs.parameters.join(' '))}`); } console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(''); @@ -875,11 +887,12 @@ async function runQueueCommand(args: string[]): Promise { '--id', parsedArgs.definitionId, '--branch', branch, ]; - if (parsedArgs.variables) { - cmdArgs.push('--variables', ...parsedArgs.variables.split(' ')); + if (parsedArgs.parameters.length > 0) { + cmdArgs.push('--parameters', ...parsedArgs.parameters); } cmdArgs.push('--output', 'json'); - console.log(`az ${cmdArgs.join(' ')}`); + const displayArgs = cmdArgs.map(a => a.includes(' ') ? `"${a}"` : a); + console.log(`az ${displayArgs.join(' ')}`); process.exit(0); } @@ -887,7 +900,7 @@ async function runQueueCommand(args: string[]): Promise { try { const client = new AzureDevOpsClient(ORGANIZATION, PROJECT); - const data = await client.queueBuild(parsedArgs.definitionId, branch, parsedArgs.variables); + const data = await client.queueBuild(parsedArgs.definitionId, branch, parsedArgs.parameters); const buildId = data.id; const buildNumber = data.buildNumber; @@ -904,10 +917,10 @@ async function runQueueCommand(args: string[]): Promise { console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(''); console.log('To check status, run:'); - console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + console.log(` node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); console.log(''); console.log('To watch progress:'); - console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId} --watch`); + console.log(` node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId} --watch`); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); console.error(colors.red('Error queuing build:')); @@ -921,7 +934,7 @@ async function runQueueCommand(args: string[]): Promise { // ============================================================================ function printStatusUsage(): void { - const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'; + const scriptName = 'node .github/skills/azure-pipelines/azure-pipeline.ts status'; console.log(`Usage: ${scriptName} [options]`); console.log(''); console.log('Get status and logs of an Azure DevOps pipeline build.'); @@ -1068,7 +1081,7 @@ async function runStatusCommand(args: string[]): Promise { if (!buildId) { console.error(colors.red(`Error: No builds found for branch '${branch}'.`)); - console.log('You can queue a new build with: node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts queue'); + console.log('You can queue a new build with: node .github/skills/azure-pipelines/azure-pipeline.ts queue'); process.exit(1); } } @@ -1162,7 +1175,7 @@ async function runStatusCommand(args: string[]): Promise { // ============================================================================ function printCancelUsage(): void { - const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts cancel'; + const scriptName = 'node .github/skills/azure-pipelines/azure-pipeline.ts cancel'; console.log(`Usage: ${scriptName} --build-id [options]`); console.log(''); console.log('Cancel a running Azure DevOps pipeline build.'); @@ -1233,7 +1246,7 @@ async function runCancelCommand(args: string[]): Promise { console.error(colors.red('Error: --build-id is required.')); console.log(''); console.log('To find build IDs, run:'); - console.log(' node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status'); + console.log(' node .github/skills/azure-pipelines/azure-pipeline.ts status'); process.exit(1); } @@ -1287,7 +1300,7 @@ async function runCancelCommand(args: string[]): Promise { console.log(''); console.log('The build will transition to "cancelling" state and then "canceled".'); console.log('Check status with:'); - console.log(` node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); + console.log(` node .github/skills/azure-pipelines/azure-pipeline.ts status --build-id ${buildId}`); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); console.error(''); @@ -1390,15 +1403,15 @@ async function runAllTests(): Promise { validateBranch(''); }); - it('validateVariables accepts valid variable formats', () => { - validateVariables('KEY=value'); - validateVariables('MY_VAR=some-value'); - validateVariables('A=1 B=2 C=3'); - validateVariables('PATH=/usr/bin:path'); + it('validateParameters accepts valid parameter formats', () => { + validateParameters(['KEY=value']); + validateParameters(['MY_VAR=some-value']); + validateParameters(['A=1', 'B=2', 'C=3']); + validateParameters(['PATH=/usr/bin:path']); }); - it('validateVariables accepts empty string', () => { - validateVariables(''); + it('validateParameters accepts empty list', () => { + validateParameters([]); }); it('validateArtifactName accepts valid artifact names', () => { @@ -1429,9 +1442,14 @@ async function runAllTests(): Promise { assert.strictEqual(args.definitionId, '222'); }); - it('parseQueueArgs parses --variables correctly', () => { - const args = parseQueueArgs(['--variables', 'KEY=value']); - assert.strictEqual(args.variables, 'KEY=value'); + it('parseQueueArgs parses --parameters correctly', () => { + const args = parseQueueArgs(['--parameters', 'KEY=value']); + assert.deepStrictEqual(args.parameters, ['KEY=value']); + }); + + it('parseQueueArgs parses repeated --parameter correctly', () => { + const args = parseQueueArgs(['--parameter', 'A=1', '--parameter', 'B=two words']); + assert.deepStrictEqual(args.parameters, ['A=1', 'B=two words']); }); it('parseQueueArgs parses --dry-run correctly', () => { @@ -1440,10 +1458,10 @@ async function runAllTests(): Promise { }); it('parseQueueArgs parses combined arguments', () => { - const args = parseQueueArgs(['--branch', 'main', '--definition', '333', '--variables', 'A=1 B=2', '--dry-run']); + const args = parseQueueArgs(['--branch', 'main', '--definition', '333', '--parameters', 'A=1 B=2', '--dry-run']); assert.strictEqual(args.branch, 'main'); assert.strictEqual(args.definitionId, '333'); - assert.strictEqual(args.variables, 'A=1 B=2'); + assert.deepStrictEqual(args.parameters, ['A=1', 'B=2']); assert.strictEqual(args.dryRun, true); }); @@ -1516,16 +1534,25 @@ async function runAllTests(): Promise { assert.ok(cmd.includes('json')); }); - it('queueBuild includes variables when provided', async () => { + it('queueBuild includes parameters when provided', async () => { const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); - await client.queueBuild('111', 'main', 'KEY=value OTHER=test'); + await client.queueBuild('111', 'main', ['KEY=value', 'OTHER=test']); const cmd = client.capturedCommands[0]; - assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('--parameters')); assert.ok(cmd.includes('KEY=value')); assert.ok(cmd.includes('OTHER=test')); }); + it('queueBuild passes parameter values with spaces verbatim', async () => { + const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); + await client.queueBuild('111', 'main', ['VSCODE_BUILD_TYPE=Product Build']); + + const cmd = client.capturedCommands[0]; + assert.ok(cmd.includes('--parameters')); + assert.deepStrictEqual(cmd[cmd.indexOf('--parameters') + 1], 'VSCODE_BUILD_TYPE=Product Build'); + }); + it('getBuild constructs correct az command', async () => { const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); await client.getBuild('12345'); @@ -1718,7 +1745,7 @@ async function runAllTests(): Promise { describe('Integration Tests', () => { it('full queue command flow constructs correct az commands', async () => { const client = new TestableAzureDevOpsClient(ORGANIZATION, PROJECT); - await client.queueBuild('111', 'feature/test', 'DEBUG=true'); + await client.queueBuild('111', 'feature/test', ['DEBUG=true']); assert.strictEqual(client.capturedCommands.length, 1); const cmd = client.capturedCommands[0]; @@ -1733,7 +1760,7 @@ async function runAllTests(): Promise { assert.ok(cmd.includes('111')); assert.ok(cmd.includes('--branch')); assert.ok(cmd.includes('feature/test')); - assert.ok(cmd.includes('--variables')); + assert.ok(cmd.includes('--parameters')); assert.ok(cmd.includes('DEBUG=true')); }); @@ -1797,7 +1824,7 @@ async function runAllTests(): Promise { // ============================================================================ function printMainUsage(): void { - const scriptName = 'node --experimental-strip-types .github/skills/azure-pipelines/azure-pipeline.ts'; + const scriptName = 'node .github/skills/azure-pipelines/azure-pipeline.ts'; console.log(`Usage: ${scriptName} [options]`); console.log(''); console.log('Azure DevOps Pipeline CLI for VS Code builds.'); diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md new file mode 100644 index 0000000000000..8d4ce8edda10a --- /dev/null +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -0,0 +1,182 @@ +--- +name: chat-customizations-editor +description: Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins. +--- + +# Chat Customizations Editor + +Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude). + +## Spec + +**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after. + +## Key Folders + +| Folder | What | +|--------|------| +| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers | +| `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration | +| `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) | +| `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar | + +When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile. + +## Key Interfaces + +- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. +- **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). +- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. + +Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. + +## Testing + +Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. + +### Screenshotting specific tabs + +The management editor fixture supports a `selectedSection` option to render any tab. Each tab has Dark/Light variants auto-generated by `defineThemedFixtureGroup`. + +**Available fixture IDs** (use with `mcp_component-exp_screenshot`): + +| Fixture ID pattern | Tab shown | +|---|---| +| `chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/{Dark,Light}` | Agents | +| `chat/aiCustomizations/aiCustomizationManagementEditor/SkillsTab/{Dark,Light}` | Skills | +| `chat/aiCustomizations/aiCustomizationManagementEditor/InstructionsTab/{Dark,Light}` | Instructions | +| `chat/aiCustomizations/aiCustomizationManagementEditor/HooksTab/{Dark,Light}` | Hooks | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PromptsTab/{Dark,Light}` | Prompts | +| `chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/{Dark,Light}` | MCP Servers | +| `chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/{Dark,Light}` | Plugins | +| `chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/{Dark,Light}` | Default (Agents, Local harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/CliHarness/{Dark,Light}` | Default (Agents, CLI harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/ClaudeHarness/{Dark,Light}` | Default (Agents, Claude harness) | +| `chat/aiCustomizations/aiCustomizationManagementEditor/Sessions/{Dark,Light}` | Sessions window variant | + +**Adding a new tab fixture:** Add a variant to the `defineThemedFixtureGroup` in `aiCustomizationManagementEditor.fixture.ts`: +```typescript +MyNewTab: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.MySection, + }), +}), +``` + +The `selectedSection` calls `editor.selectSectionById()` after `setInput`, which navigates to the specified tab and re-layouts. + +### Populating test data + +Each customization type requires its own mock path in `createMockPromptsService`: +- **Agents** — `getCustomAgents()` returns agent objects +- **Skills** — `findAgentSkills()` returns `IAgentSkill[]` +- **Prompts** — `getPromptSlashCommands()` returns `IChatPromptSlashCommand[]` +- **Instructions/Hooks** — `listPromptFiles()` filtered by `PromptsType` +- **MCP Servers** — `mcpWorkspaceServers`/`mcpUserServers` arrays passed to `IMcpWorkbenchService` mock +- **Plugins** — `IPluginMarketplaceService.installedPlugins` and `IAgentPluginService.plugins` observables + +All test data lives in `allFiles` (prompt-based items) and the `mcpWorkspace/UserServers` arrays. Add enough items per category (8+) to invoke scrolling. + +### Exercising built-in grouping + +The list widget regroups items from the default chat extension under a "Built-in" header. Three things must be in place for fixtures to exercise this: +1. Include `BUILTIN_STORAGE` in the harness descriptor's visible sources +2. Mock `IProductService.defaultChatAgent.chatExtensionId` (e.g., `'GitHub.copilot-chat'`) +3. Give mock items extension provenance via `extensionId` / `extensionDisplayName` matching that ID + +Without all three, built-in regrouping silently doesn't run and the fixture only shows flat lists. + +### Editor contribution service mocks + +The management editor embeds a `CodeEditorWidget`. Electron-side editor contributions (e.g., `AgentFeedbackEditorWidgetContribution`) are instantiated automatically and crash if their injected services aren't registered. The fixture must mock at minimum: +- `IAgentFeedbackService` — needs `onDidChangeFeedback`, `onDidChangeNavigation` as `Event.None` +- `ICodeReviewService` — needs `getReviewState()` / `getPRReviewState()` returning idle observables +- `IChatEditingService` — needs `editingSessionsObs` as empty observable +- `IAgentSessionsService` — needs `model.sessions` as empty array + +These are cross-layer imports from `vs/sessions/` — use `// eslint-disable-next-line local/code-import-patterns` on the import lines. + +### CI regression gates + +Key fixtures have `blocksCi: true` in their labels. The `screenshot-test.yml` GitHub Action captures screenshots on every PR to `main` and **fails the CI status check** if any `blocks-ci`-labeled fixture's screenshot changes. This catches layout regressions automatically. + +Currently gated fixtures: `LocalHarness`, `McpServersTab`, `McpServersTabNarrow`, `AgentsTabNarrow`. When adding a new section or layout-critical fixture, add `blocksCi: true`: + +```typescript +MyFixture: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: true }, + render: ctx => renderEditor(ctx, { ... }), +}), +``` + +Don't add `blocksCi` to every fixture — only ones that cover critical layout paths (default view, section with list + footer, narrow viewport). Too many gated fixtures creates noisy CI. + +### Screenshot stability + +Scrollbar fade transitions cause screenshot instability — the scrollbar shifts from `visible` to `invisible fade` class ~2 seconds after a programmatic scroll. After calling `revealLastItem()` or any scroll action, wait for the transition to complete before the fixture's render promise resolves: + +```typescript +await new Promise(resolve => setTimeout(resolve, 2400)); +// Then optionally poll until .scrollbar.vertical loses the 'visible' class +``` + +### Running unit tests + +```bash +./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" +npm run compile-check-ts-native && npm run valid-layers-check +``` + +See the `sessions` skill for sessions-window specific guidance. + +## Debugging Layout in the Real Product + +Component fixtures use mock data and a fixed container size. Layout bugs caused by reflow timing, real data shapes, or narrow window sizes often **don't reproduce in fixtures**. When a user reports a broken layout, debug in the live Code OSS product. + +For launching Code OSS with CDP and connecting `agent-browser`, see the **`launch` skill**. Use `--user-data-dir /tmp/code-oss-debug` to avoid colliding with an already-running instance from another worktree. + +### Navigating to the customizations editor + +After connecting, use `snapshot -i` to find the "Open Customizations" button (in the Chat panel header), then click it. To switch sections, use `eval` with a DOM click since sidebar items aren't interactive refs: + +```bash +npx agent-browser eval "const items = [...document.querySelectorAll('.section-list-item')]; \ + items.find(el => el.textContent?.includes('MCP'))?.click();" +``` + +### Inspecting widget layout + +`agent-browser eval` doesn't always print return values. Use `document.title` as a return channel: + +```bash +npx agent-browser eval "const w = document.querySelector('.mcp-list-widget'); \ + const lc = w?.querySelector('.mcp-list-container'); \ + const rows = lc?.querySelectorAll('.monaco-list-row'); \ + document.title = 'DBG:rows=' + (rows?.length ?? -1) \ + + ',listH=' + (lc?.offsetHeight ?? -1) \ + + ',seStH=' + (lc?.querySelector('.monaco-scrollable-element')?.style?.height ?? '') \ + + ',wH=' + (w?.offsetHeight ?? -1);" +npx agent-browser eval "document.title" 2>&1 +``` + +Key diagnostics: +- **`rows`** — fewer than expected means `list.layout()` never received the correct viewport height. +- **`seStH`** — empty means the list was never properly laid out. +- **`listH` vs `wH`** — list container height should be widget height minus search bar minus footer. + +### Common layout issues + +| Symptom | Root cause | Fix pattern | +|---------|-----------|-------------| +| List shows 0-1 rows in a tall container | `layout()` bailed out because `offsetHeight` returned 0 during `display:none → visible` transition | Defer layout via `DOM.getWindow(this.element).requestAnimationFrame(...)` | +| Badge or row content clips at right edge | Widget container missing `overflow: hidden` | Add `overflow: hidden` to the widget's CSS class | +| Items visible in fixture but not in product | Fixture uses many mock items; real product has few | Add fixture variants with fewer items or narrower dimensions (`width`/`height` options) | + +### Fixture vs real product gaps + +Fixtures render at a fixed size (default 900×600) with many mock items. They won't catch: +- **Reflow timing** — the real product's `display:none → visible` transition may not have reflowed before `layout()` fires +- **Narrow windows** — add narrow fixture variants (e.g., `width: 550, height: 400`) +- **Real data counts** — a user with 1 MCP server sees very different layout than a fixture with 12 diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md new file mode 100644 index 0000000000000..6c7eb5a6059dc --- /dev/null +++ b/.github/skills/component-fixtures/SKILL.md @@ -0,0 +1,343 @@ +--- +name: component-fixtures +description: Use when creating or updating component fixtures for screenshot testing, or when designing UI components to be fixture-friendly. Covers fixture file structure, theming, service setup, CSS scoping, async rendering, and common pitfalls. +--- + +# Component Fixtures + +Component fixtures render isolated UI components for visual screenshot testing via the component explorer. Fixtures live in `src/vs/workbench/test/browser/componentFixtures/` and are auto-discovered by the Vite dev server using the glob `src/**/*.fixture.ts`. + +Use tools `mcp_component-exp_`* to list and screenshot fixtures. If you cannot see these tools, inform the user to them on. + +## Running Fixtures Locally + +1. Start the component explorer daemon: run the **Launch Component Explorer** task +2. Use the `mcp_component-exp_list_fixtures` tool to see all available fixtures and their URLs +3. Use the `mcp_component-exp_screenshot` tool to capture screenshots programmatically + +## File Structure + +Each fixture file exports a default `defineThemedFixtureGroup(...)`. The file must end with `.fixture.ts`. + +``` +src/vs/workbench/test/browser/componentFixtures/ + fixtureUtils.ts # Shared helpers (DO NOT import @vscode/component-explorer elsewhere) + myComponent.fixture.ts # Your fixture file +``` + +## Basic Pattern + +```typescript +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +export default defineThemedFixtureGroup({ path: 'myFeature/' }, { + Default: defineComponentFixture({ render: renderMyComponent }), + AnotherVariant: defineComponentFixture({ render: renderMyComponent }), +}); + +function renderMyComponent({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '400px'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + // Register additional services the component needs + reg.define(IMyService, MyServiceImpl); + reg.defineInstance(IMockService, mockInstance); + }, + }); + + const widget = disposableStore.add( + instantiationService.createInstance(MyWidget, /* constructor args */) + ); + container.appendChild(widget.domNode); +} +``` + +Key points: +- **`defineThemedFixtureGroup`** automatically creates Dark and Light variants for each fixture +- **`defineComponentFixture`** wraps your render function with theme setup and shadow DOM isolation +- **`createEditorServices`** provides a `TestInstantiationService` with base editor services pre-registered +- Always register created widgets with `disposableStore.add(...)` to prevent leaks +- Pass `colorTheme: theme` to `createEditorServices` so theme colors render correctly + +## Utilities from fixtureUtils.ts + +| Export | Purpose | +|---|---| +| `defineComponentFixture` | Creates Dark/Light themed fixture variants from a render function | +| `defineThemedFixtureGroup` | Groups multiple themed fixtures into a named fixture group | +| `createEditorServices` | Creates `TestInstantiationService` with all base editor services | +| `registerWorkbenchServices` | Registers additional workbench services (context menu, label, etc.) | +| `createTextModel` | Creates a text model via `ModelService` for editor fixtures | +| `setupTheme` | Applies theme CSS to a container (called automatically by `defineComponentFixture`) | +| `darkTheme` / `lightTheme` | Pre-loaded `ColorThemeData` instances | + +**Important:** Only `fixtureUtils.ts` may import from `@vscode/component-explorer`. All fixture files must go through the helpers in `fixtureUtils.ts`. + +## CSS Scoping + +Fixtures render inside shadow DOM. The component-explorer automatically adopts the global VS Code stylesheets and theme CSS. + +### Matching production CSS selectors + +Many VS Code components have CSS rules scoped to deep ancestor selectors (e.g., `.interactive-session .interactive-input-part > .widget-container .my-element`). In fixtures, you must recreate the required ancestor DOM structure for these selectors to match: + +```typescript +function render({ container }: ComponentFixtureContext): void { + container.classList.add('interactive-session'); + + // Recreate ancestor structure that CSS selectors expect + const inputPart = dom.$('.interactive-input-part'); + const widgetContainer = dom.$('.widget-container'); + inputPart.appendChild(widgetContainer); + container.appendChild(inputPart); + + widgetContainer.appendChild(myWidget.domNode); +} +``` + +**Design recommendation for new components:** Avoid deeply nested CSS selectors that require specific ancestor elements. Use self-contained class names (e.g., `.my-widget .my-element` rather than `.parent-view .parent-part > .wrapper .my-element`). This makes components easier to fixture and reuse. + +## Services + +### Using createEditorServices + +`createEditorServices` pre-registers these services: `IAccessibilityService`, `IKeybindingService`, `IClipboardService`, `IOpenerService`, `INotificationService`, `IDialogService`, `IUndoRedoService`, `ILanguageService`, `IConfigurationService`, `IStorageService`, `IThemeService`, `IModelService`, `ICodeEditorService`, `IContextKeyService`, `ICommandService`, `ITelemetryService`, `IHoverService`, `IUserInteractionService`, and more. + +### Additional services + +Register extra services via `additionalServices`: + +```typescript +createEditorServices(disposableStore, { + additionalServices: (reg) => { + // Class-based (instantiated by DI): + reg.define(IMyService, MyServiceImpl); + // Instance-based (pre-constructed): + reg.defineInstance(IMyService, myMockInstance); + }, +}); +``` + +### Mocking services + +Use the `mock()` helper from `base/test/common/mock.js` to create mock service instances: + +```typescript +import { mock } from '../../../../base/test/common/mock.js'; + +const myService = new class extends mock() { + override someMethod(): string { return 'test'; } + override onSomeEvent = Event.None; +}; +reg.defineInstance(IMyService, myService); +``` + +For mock view models or data objects: +```typescript +const element = new class extends mock() { }(); +``` + +## Async Rendering + +The component explorer waits **2 animation frames** after the synchronous render function returns. For most components, this is sufficient. + +If your render function returns a `Promise`, the component explorer waits for the promise to resolve. + +### Pitfall: DOM reparenting causes flickering + +Avoid moving rendered widgets between DOM parents after initial render. This causes: +- Layout recalculation (the widget jumps as `position: absolute` coordinates become invalid) +- Focus loss (blur events can trigger hide logic in widgets like QuickInput) +- Screenshot instability (the component explorer may capture an intermediate layout state) + +**Bad pattern — reparenting a widget after async wait:** +```typescript +async function render({ container }: ComponentFixtureContext): Promise { + const host = document.createElement('div'); + container.appendChild(host); + // ... create widget inside host ... + await waitForWidget(); + container.appendChild(widget); // BAD: reparenting causes flicker + host.remove(); +} +``` + +**Better pattern — render in-place with the correct DOM structure from the start:** +```typescript +function render({ container }: ComponentFixtureContext): void { + // Set up the correct DOM structure first, then create the widget inside it + const widget = createWidget(container); + container.appendChild(widget.domNode); +} +``` + +If the component absolutely requires async setup (e.g., QuickInput which renders internally), minimize DOM manipulation after the widget appears by structuring the host container to match the final layout from the beginning. + +## Adapting Existing Components for Fixtures + +Existing components often need small changes to become fixturable. When writing a fixture reveals friction, fix the component — don't work around it in the fixture. Common adaptations: + +### Decouple CSS from ancestor context + +If a component's CSS only works inside a deeply nested selector like `.workbench .sidebar .my-view .my-widget`, refactor the CSS to be self-contained. Move the styles so they're scoped to the component's own root class: + +```css +/* Before: requires specific ancestors */ +.workbench .sidebar .my-view .my-widget .header { font-weight: bold; } + +/* After: self-contained */ +.my-widget .header { font-weight: bold; } +``` + +If the component shares styles with its parent (e.g., inheriting background color), use CSS custom properties rather than relying on ancestor selectors. + +### Extract hard-coded service dependencies + +If a component reaches into singletons or global state instead of using DI, refactor it to accept services through the constructor: + +```typescript +// Before: hard to mock in fixtures +class MyWidget { + private readonly config = getSomeGlobalConfig(); +} + +// After: injectable and testable +class MyWidget { + constructor(@IConfigurationService private readonly configService: IConfigurationService) { } +} +``` + +### Add options to control auto-focus and animation + +Components that auto-focus on creation or run animations cause flaky screenshots. Add an options parameter: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; +} +``` + +The fixture passes `shouldAutoFocus: false`. The production call site keeps the default behavior. + +### Expose internal state for "already completed" rendering + +Many components have lifecycle states (loading → active → completed). If the component can only reach the "completed" state through user interaction, add support for initializing directly into that state via constructor data: + +```typescript +// The fixture can pass pre-filled data to render the summary/completed state +// without simulating the full user interaction flow. +const carousel: IChatQuestionCarousel = { + questions, + allowSkip: true, + kind: 'questionCarousel', + isUsed: true, // Already completed + data: { 'q1': 'answer' }, // Pre-filled answers +}; +``` + +### Make DOM node accessible + +If a component builds its DOM internally and doesn't expose the root element, add a public `readonly domNode: HTMLElement` property so fixtures can append it to the container. + +## Writing Fixture-Friendly Components + +When designing new UI components, follow these practices to make them easy to fixture: + +### 1. Accept a container element in the constructor + +```typescript +// Good: container is passed in +class MyWidget { + constructor(container: HTMLElement, @IFoo foo: IFoo) { + this.domNode = dom.append(container, dom.$('.my-widget')); + } +} + +// Also good: widget creates its own domNode for the caller to place +class MyWidget { + readonly domNode: HTMLElement; + constructor(@IFoo foo: IFoo) { + this.domNode = dom.$('.my-widget'); + } +} +``` + +### 2. Use dependency injection for all services + +All external dependencies should come through DI so fixtures can provide test implementations: + +```typescript +// Good: services injected +constructor(@IThemeService private readonly themeService: IThemeService) { } + +// Bad: reaching into globals +constructor() { this.theme = getGlobalTheme(); } +``` + +### 3. Keep CSS selectors shallow + +```css +/* Good: self-contained, easy to fixture */ +.my-widget .my-header { ... } +.my-widget .my-list-item { ... } + +/* Bad: requires deep ancestor chain */ +.workbench .sidebar .my-view .my-widget .my-header { ... } +``` + +### 4. Avoid reading from layout/window services during construction + +Components that measure the window or read layout dimensions during construction are hard to fixture because the shadow DOM container has different dimensions than the workbench: + +```typescript +// Prefer: use CSS for sizing, or accept dimensions as parameters +container.style.width = '400px'; +container.style.height = '300px'; + +// Avoid: reading from layoutService during construction +const width = this.layoutService.mainContainerDimension.width; +``` + +### 5. Support disabling auto-focus in fixtures + +Auto-focus can interfere with screenshot stability. Provide options to disable it: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; // Fixtures pass false +} +``` + +### 6. Expose the DOM node + +The fixture needs to append the widget's DOM to the container. Expose it as a public `readonly domNode: HTMLElement`. + +## Multiple Fixture Variants + +Create variants to show different states of the same component: + +```typescript +export default defineThemedFixtureGroup({ + // Different data states + Empty: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: [] }) }), + WithItems: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: sampleItems }) }), + + // Different configurations + ReadOnly: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: true }) }), + Editable: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: false }) }), + + // Lifecycle states + Loading: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'loading' }) }), + Completed: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'done' }) }), +}); +``` + +## Learnings + +Update this section with insights from your fixture development experience! + +* Do not copy the component to the fixture and modify it there. Always adapt the original component to be fixture-friendly, then render it in the fixture. This ensures the fixture tests the real component code and lifecycle, rather than a modified version that may hide bugs. + +* **Don't recompose child widgets in fixtures.** Never manually instantiate and add a sub-widget (e.g., a toolbar content widget) that the parent component is supposed to create. Instead, configure the parent correctly (e.g., set the right editor option, register the right provider) so the child appears through the normal code path. Manually recomposing hides integration bugs and doesn't test the real widget lifecycle. diff --git a/.github/skills/fix-ci-failures/SKILL.md b/.github/skills/fix-ci-failures/SKILL.md new file mode 100644 index 0000000000000..4e05478ad78b5 --- /dev/null +++ b/.github/skills/fix-ci-failures/SKILL.md @@ -0,0 +1,269 @@ +--- +name: fix-ci-failures +description: Investigate and fix CI failures on a pull request. Use when CI checks fail on a PR branch — covers finding the PR, identifying failed checks, downloading logs and artifacts, extracting the failure cause, and iterating on a fix. Requires the `gh` CLI. +--- + +# Investigating and Fixing CI Failures + +This skill guides you through diagnosing and fixing CI failures on a PR using the `gh` CLI. The user has the PR branch checked out locally. + +## Workflow Overview + +1. Identify the current branch and its PR +2. Check CI status and find failed checks +3. Download logs for failed jobs +4. Extract and understand the failure +5. Fix the issue and push + +--- + +## Step 1: Identify the Branch and PR + +```bash +# Get the current branch name +git branch --show-current + +# Find the PR for this branch +gh pr view --json number,title,url,statusCheckRollup +``` + +If no PR is found, the user may need to specify the PR number. + +--- + +## Step 2: Check CI Status + +```bash +# List all checks and their status (pass/fail/pending) +gh pr checks --json name,state,link,bucket + +# Filter to only failed checks +gh pr checks --json name,state,link,bucket --jq '.[] | select(.bucket == "fail")' +``` + +The `link` field contains the URL to the GitHub Actions job. Extract the **run ID** from the URL — it's the number after `/runs/`: +``` +https://github.com/microsoft/vscode/actions/runs//job/ +``` + +If checks are still `IN_PROGRESS`, wait for them to complete before downloading logs: +```bash +gh pr checks --watch --fail-fast +``` + +--- + +## Step 3: Get Failed Job Details + +```bash +# List failed jobs in a run (use the run ID from the check link) +gh run view --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name: .name, id: .databaseId}' +``` + +--- + +## Step 4: Download Failure Logs + +There are two approaches depending on the type of failure. + +### Option A: View Failed Step Logs Directly + +Best for build/compile/lint failures where the error is in the step output: + +```bash +# View only the failed step logs (most useful — shows just the errors) +gh run view --job --log-failed +``` + +> **Important**: `--log-failed` requires the **entire run** to complete, not just the failed job. If other jobs are still running, this command will block or error. Use **Option C** below to get logs for a completed job while the run is still in progress. + +The output can be large. Pipe through `tail` or `grep` to focus: +```bash +# Last 100 lines of failed output +gh run view --job --log-failed | tail -100 + +# Search for common error patterns +gh run view --job --log-failed | grep -E "Error|FAIL|error TS|AssertionError|failing" +``` + +### Option B: Download Artifacts + +Best for integration test failures where detailed logs (terminal logs, ext host logs, crash dumps) are uploaded as artifacts: + +```bash +# List available artifacts for a run +gh run download --pattern '*' --dir /dev/null 2>&1 || gh run view --json jobs --jq '.jobs[].name' + +# Download log artifacts for a specific failed job +# Artifact naming convention: logs---- +# Examples: logs-linux-x64-electron-1, logs-linux-x64-remote-1 +gh run download -n "logs-linux-x64-electron-1" -D /tmp/ci-logs + +# Download crash dumps if available +gh run download -n "crash-dump-linux-x64-electron-1" -D /tmp/ci-crashes +``` + +> **Tip**: Use the test runner name from the failed check (e.g., "Linux / Electron" → `electron`, "Linux / Remote" → `remote`) and platform map ("Windows" → `windows-x64`, "Linux" → `linux-x64`, "macOS" → `macos-arm64`) to construct the artifact name. + +> **Warning**: Log artifacts may be empty if the test runner crashed before producing output (e.g., Electron download failure). In that case, fall back to **Option C**. + +### Option C: Download Per-Job Logs via API (works while run is in progress) + +When the run is still in progress but the failed job has completed, use the GitHub API to download that job's step logs directly: + +```bash +# Save the full job log to a temp file (can be very large — 30k+ lines) +gh api repos/microsoft/vscode/actions/jobs//logs > "$TMPDIR/ci-job-log.txt" +``` + +Then search the saved file. **Start with `##[error]`** — this is the GitHub Actions error annotation that marks the exact line where the step failed: + +```bash +# Step 1: Find the error annotation (fastest path to the failure) +grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt" + +# Step 2: Read context around the error (e.g., if error is on line 34371, read 200 lines before it) +sed -n '34171,34371p' "$TMPDIR/ci-job-log.txt" +``` + +If `##[error]` doesn't reveal enough, use broader patterns: +```bash +# Find test failures, exceptions, and crash indicators +grep -n -E 'HTTPError|ECONNRESET|ETIMEDOUT|502|exit code|Process completed|node:internal|triggerUncaughtException' "$TMPDIR/ci-job-log.txt" | head -20 +``` + +> **Why save to a file?** The API response for a full job log can be 30k+ lines. Tool output gets truncated, so always redirect to a file first, then search. + +### VS Code Log Artifacts Structure + +Downloaded log artifacts typically contain: +``` +logs-linux-x64-electron-1/ + main.log # Main process log + terminal.log # Terminal/pty host log (key for run_in_terminal issues) + window1/ + renderer.log # Renderer process log + exthost/ + exthost.log # Extension host log (key for extension test failures) +``` + +Key files to examine first: +- **Test assertion failures**: Check `exthost.log` for the extension host output and stack traces +- **Terminal/sandbox issues**: Check `terminal.log` for rewriter pipeline, shell integration, and strategy logs +- **Crash/hang**: Check `main.log` and look for crash dumps artifacts + +--- + +## Step 5: Extract the Failure + +### For Test Failures + +Look for the test runner output in the failed step log: +```bash +# Find failing test names and assertion messages +gh run view --job --log-failed | grep -A 5 "failing\|AssertionError\|Expected\|Unexpected" +``` + +Common patterns in VS Code CI: +- **`AssertionError [ERR_ASSERTION]`**: Test assertion failed — check expected vs actual values +- **`Extension host test runner exit code: 1`**: Integration test suite had failures +- **`Command produced no output`**: Shell integration may not have captured command output (see terminal.log) +- **`Error: Timeout`**: Test timed out — could be a hang or slow CI machine + +### For Build Failures + +```bash +# Find TypeScript compilation errors +gh run view --job --log-failed | grep "error TS" + +# Find hygiene/lint errors +gh run view --job --log-failed | grep -E "eslint|stylelint|hygiene" +``` + +--- + +## Step 6: Determine if Failures are Related to the PR + +Before fixing, determine if the failure is caused by the PR changes or is a pre-existing/infrastructure issue: + +1. **Check if the failing test is in code you changed** — if the test is in a completely unrelated area, it may be a flake +2. **Check the test name** — does it relate to the feature area you modified? +3. **Look at the failure output** — does it reference code paths your PR touches? +4. **Check if the same tests fail on main** — if identical failures exist on recent main commits, it's a pre-existing issue +5. **Look for infrastructure failures** — network timeouts, npm registry errors, and machine-level issues are not caused by code changes + +```bash +# Check recent runs on main for the same workflow +gh run list --branch main --workflow pr-linux-test.yml --limit 5 --json databaseId,conclusion,displayTitle +``` + +### Recognizing Infrastructure / Flaky Failures + +Not all CI failures are caused by code changes. Common infrastructure failures: + +**Network / Registry issues**: +- `npm ERR! network`, `ETIMEDOUT`, `ECONNRESET`, `EAI_AGAIN` — npm registry unreachable +- `error: RPC failed; curl 56`, `fetch-pack: unexpected disconnect` — git network failure +- `Error: unable to get local issuer certificate` — TLS/certificate issues +- `rate limit exceeded` — GitHub API rate limiting +- `HTTPError: Request failed with status code 502` on `electron/electron/releases` — Electron CDN download failure (common in the `node.js integration tests` step, which downloads Electron at runtime) + +**Machine / Environment issues**: +- `No space left on device` — CI disk full +- `ENOMEM`, `JavaScript heap out of memory` — CI machine ran out of memory +- `The runner has received a shutdown signal` — CI preemption / timeout +- `Error: The operation was canceled` — GitHub Actions cancelled the job +- `Xvfb failed to start` — display server for headless Linux tests failed + +**Test flakes** (not infrastructure, but not your fault either): +- Timeouts on tests that normally pass — slow CI machine +- Race conditions in async tests +- Shell integration not reporting exit codes (see terminal.log for `exitCode: undefined`) + +**What to do with infrastructure failures**: +1. **Don't change code** — the failure isn't caused by your PR +2. **Re-run the failed jobs** via the GitHub UI or: + ```bash + gh run rerun --failed + ``` +3. If failures persist across re-runs, check if main is also broken: + ```bash + gh run list --branch main --limit 10 --json databaseId,conclusion,displayTitle + ``` +4. If main is broken too, wait for it to be fixed — your PR is not the cause + +--- + +## Step 7: Fix and Iterate + +1. Make the fix locally +2. Verify compilation: check the `VS Code - Build` task or run `npm run compile-check-ts-native` +3. Run relevant unit tests locally: `./scripts/test.sh --grep ""` +4. Commit and push: + ```bash + git add -A + git commit -m "fix: " + git push + ``` +5. Watch CI again: + ```bash + gh pr checks --watch --fail-fast + ``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Find PR for branch | `gh pr view --json number,url` | +| List all checks | `gh pr checks --json name,state,bucket` | +| List failed checks only | `gh pr checks --json name,state,link,bucket --jq '.[] \| select(.bucket == "fail")'` | +| Watch checks until done | `gh pr checks --watch --fail-fast` | +| Failed jobs in a run | `gh run view --json jobs --jq '.jobs[] \| select(.conclusion == "failure") \| {name, id: .databaseId}'` | +| View failed step logs | `gh run view --job --log-failed` (requires full run to complete) | +| Download job log via API | `gh api repos/microsoft/vscode/actions/jobs//logs > "$TMPDIR/ci-job-log.txt"` (works while run is in progress) | +| Find error line in log | `grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt"` | +| Download log artifacts | `gh run download -n "" -D /tmp/ci-logs` | +| Re-run failed jobs | `gh run rerun --failed` | +| Recent main runs | `gh run list --branch main --workflow .yml --limit 5` | diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index fc49548b7a384..e39957e5f66bc 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -15,8 +15,6 @@ The `src/vs/sessions/` directory contains authoritative specification documents. | Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | | AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | | Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | -| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | -| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. @@ -62,44 +60,57 @@ src/vs/sessions/ ├── AI_CUSTOMIZATIONS.md # AI customization design document ├── sessions.common.main.ts # Common (browser + desktop) entry point ├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) -├── common/ # Shared types and context keys -│ └── contextkeys.ts # ChatBar context keys +├── common/ # Shared types, context keys, and theme +│ ├── categories.ts # Command categories +│ ├── contextkeys.ts # ChatBar and welcome context keys +│ └── theme.ts # Theme contributions ├── browser/ # Core workbench implementation │ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) │ ├── menus.ts # Agent sessions menu IDs (Menus export) │ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) │ ├── paneCompositePartService.ts # AgenticPaneCompositePartService -│ ├── style.css # Layout-specific styles │ ├── widget/ # Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc -│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget -│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state -│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -│ │ └── media/ -│ └── parts/ # Workbench part implementations -│ ├── parts.ts # AgenticParts enum -│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) -│ ├── sidebarPart.ts # Sidebar (with footer for account widget) -│ ├── chatBarPart.ts # Chat Bar (primary chat surface) -│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) -│ ├── panelPart.ts # Panel (terminal, output, etc.) -│ ├── projectBarPart.ts # Project bar (folder entries) -│ ├── agentSessionsChatInputPart.ts # Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) -│ └── media/ # Part CSS files +│ │ └── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ ├── parts/ # Workbench part implementations +│ │ ├── parts.ts # AgenticParts enum +│ │ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ │ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ │ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ │ ├── auxiliaryBarPart.ts # Auxiliary Bar +│ │ ├── panelPart.ts # Panel (terminal, output, etc.) +│ │ ├── projectBarPart.ts # Project bar (folder entries) +│ │ └── media/ # Part CSS files +│ └── media/ # Layout-specific styles ├── electron-browser/ # Desktop-specific entry points │ ├── sessions.main.ts # Desktop main bootstrap │ ├── sessions.ts # Electron process entry │ ├── sessions.html # Production HTML shell -│ └── sessions-dev.html # Development HTML shell +│ ├── sessions-dev.html # Development HTML shell +│ ├── titleService.ts # Desktop title service override +│ └── parts/ +│ └── titlebarPart.ts # Desktop titlebar part +├── services/ # Service overrides +│ ├── configuration/browser/ # Configuration service overrides +│ └── workspace/browser/ # Workspace service overrides +├── test/ # Unit tests +│ └── browser/ +│ └── layoutActions.test.ts └── contrib/ # Feature contributions ├── accountMenu/browser/ # Account widget for sidebar footer - ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── agentFeedback/browser/ # Agent feedback attachments, overlays, hover ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── applyToParentRepo/browser/ # Apply changes to parent repo ├── changesView/browser/ # File changes view ├── chat/browser/ # Chat actions (run script, branch, prompts) ├── configuration/browser/ # Configuration overrides - └── sessions/browser/ # Sessions view, title bar widget, active session service + ├── files/browser/ # File-related contributions + ├── fileTreeView/browser/ # File tree view (filesystem provider) + ├── gitSync/browser/ # Git sync contributions + ├── logs/browser/ # Log contributions + ├── sessions/browser/ # Sessions view, title bar widget, active session service + ├── terminal/browser/ # Terminal contributions + ├── welcome/browser/ # Welcome view contribution + └── workspace/browser/ # Workspace contributions ``` ## 4. Layout @@ -165,18 +176,21 @@ The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts | Menu ID | Purpose | |---------|---------| -| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | -| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | -| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.ChatBarTitle` | Chat bar title actions | | `Menus.CommandCenter` | Center toolbar with session picker widget | -| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.CommandCenterCenter` | Center section of command center | +| `Menus.TitleBarContext` | Titlebar context menu | +| `Menus.TitleBarLeftLayout` | Left layout toolbar | +| `Menus.TitleBarSessionTitle` | Session title in titlebar | +| `Menus.TitleBarSessionMenu` | Session menu in titlebar | +| `Menus.TitleBarRightLayout` | Right layout toolbar | | `Menus.PanelTitle` | Panel title bar actions | | `Menus.SidebarTitle` | Sidebar title bar actions | | `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.SidebarCustomizations` | Sidebar customizations menu | | `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | | `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | -| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | -| `Menus.ChatBarTitle` | Chat bar title actions | +| `Menus.AgentFeedbackEditorContent` | Agent feedback editor content menu | ## 7. Context Keys @@ -187,7 +201,7 @@ Defined in `common/contextkeys.ts`: | `activeChatBar` | `string` | ID of the active chat bar panel | | `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | | `chatBarVisible` | `boolean` | Whether chat bar is visible | - +| `sessionsWelcomeVisible` | `boolean` | Whether the sessions welcome overlay is visible | ## 8. Contributions Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). @@ -199,13 +213,18 @@ Feature contributions live under `contrib//browser/` and are regist | **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | | **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | | **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | -| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | -| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | -| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | -| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Chat Actions** | `contrib/chat/browser/` | Chat actions (run script, branch, prompts, customizations debug log) | | **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | -| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **Agent Feedback** | `contrib/agentFeedback/browser/` | Agent feedback attachments, editor overlays, hover | | **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Apply to Parent Repo** | `contrib/applyToParentRepo/browser/` | Apply changes to parent repo | +| **Files** | `contrib/files/browser/` | File-related contributions | +| **File Tree View** | `contrib/fileTreeView/browser/` | File tree view (filesystem provider) | +| **Git Sync** | `contrib/gitSync/browser/` | Git sync contributions | +| **Logs** | `contrib/logs/browser/` | Log contributions | +| **Terminal** | `contrib/terminal/browser/` | Terminal contributions | +| **Welcome** | `contrib/welcome/browser/` | Welcome view contribution | +| **Workspace** | `contrib/workspace/browser/` | Workspace contributions | | **Configuration** | `contrib/configuration/browser/` | Configuration overrides | ### 8.2 Service Overrides @@ -216,6 +235,10 @@ The agent sessions window registers its own implementations for: - `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) - `IActiveSessionService` → `ActiveSessionService` (tracks active session) +Service overrides also live under `services/`: +- `services/configuration/browser/` - configuration service overrides +- `services/workspace/browser/` - workspace service overrides + ### 8.3 `WindowVisibility.Sessions` Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. @@ -224,12 +247,14 @@ Views and contributions that should only appear in the agent sessions window (no | File | Purpose | |------|---------| -| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | -| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `sessions.common.main.ts` | Common entry; imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry; imports desktop services, electron contributions, all `contrib/` modules | | `electron-browser/sessions.main.ts` | Desktop bootstrap | | `electron-browser/sessions.ts` | Electron process entry | | `electron-browser/sessions.html` | Production HTML shell | | `electron-browser/sessions-dev.html` | Development HTML shell | +| `electron-browser/titleService.ts` | Desktop title service override | +| `electron-browser/parts/titlebarPart.ts` | Desktop titlebar part | ## 10. Development Guidelines @@ -243,7 +268,15 @@ Views and contributions that should only appear in the agent sessions window (no 6. Use agent session part classes, not standard workbench parts 7. Mark views with `WindowVisibility.Sessions` so they only appear in this window -### 10.2 Layout Changes +### 10.2 Validating Changes + +1. Run `npm run compile-check-ts-native` to run a repo-wide TypeScript compilation check (including `src/vs/sessions/`). This is a fast way to catch TypeScript errors introduced by your changes. +2. Run `npm run valid-layers-check` to verify layering rules are not violated. +3. Use `scripts/test.sh` (or `scripts\test.bat` on Windows) for unit tests (add `--grep ` to filter tests) + +**Important** do not run `tsc` to check for TypeScript errors always use above methods to validate TypeScript changes in `src/vs/**`. + +### 10.3 Layout Changes 1. **Read `LAYOUT.md` first** — it's the authoritative spec 2. Use the `agent-sessions-layout` skill for detailed implementation guidance diff --git a/.github/skills/unit-tests/SKILL.md b/.github/skills/unit-tests/SKILL.md new file mode 100644 index 0000000000000..f2a8b66c5a374 --- /dev/null +++ b/.github/skills/unit-tests/SKILL.md @@ -0,0 +1,87 @@ +--- +name: unit-tests +description: Use when running unit tests in the VS Code repo. Covers the runTests tool, scripts/test.sh (macOS/Linux) and scripts/test.bat (Windows), and their supported arguments for filtering, globbing, and debugging tests. +--- + +# Running Unit Tests + +## Preferred: Use the `runTests` tool + +If the `runTests` tool is available, **prefer it** over running shell commands. It provides structured output with detailed pass/fail information and supports filtering by file and test name. + +- Pass absolute paths to test files via the `files` parameter. +- Pass test names via the `testNames` parameter to filter which tests run. +- Set `mode="coverage"` to collect coverage. + +Example (conceptual): run tests in `src/vs/editor/test/common/model.test.ts` with test name filter `"should split lines"`. + +## Fallback: Shell scripts + +When the `runTests` tool is not available (e.g. in CLI environments), use the platform-appropriate script from the repo root: + +- **macOS / Linux:** `./scripts/test.sh [options]` +- **Windows:** `.\scripts\test.bat [options]` + +These scripts download Electron if needed and launch the Mocha test runner. + +### Commonly used options + +#### `--run ` - Run tests from a specific file + +Accepts a **source file path** (starting with `src/`). The runner strips the `src/` prefix and the `.ts`/`.js` extension automatically to resolve the compiled module. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts +``` + +Multiple files can be specified by repeating `--run`: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --run src/vs/editor/test/common/range.test.ts +``` + +#### `--grep ` (aliases: `-g`, `-f`) - Filter tests by name + +Runs only tests whose full title matches the pattern (passed to Mocha's `--grep`). + +```bash +./scripts/test.sh --grep "should split lines" +``` + +Combine with `--run` to filter tests within a specific file: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --grep "should split lines" +``` + +#### `--runGlob ` (aliases: `--glob`, `--runGrep`) - Run tests matching a glob + +Runs all test files matching a glob pattern against the compiled output directory. Useful for running all tests under a feature area. + +```bash +./scripts/test.sh --runGlob "**/editor/test/**/*.test.js" +``` + +Note: the glob runs against compiled `.js` files in the output directory, not source `.ts` files. + +#### `--coverage` - Generate a coverage report + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --coverage +``` + +#### `--timeout ` - Set test timeout + +Override the default Mocha timeout for long-running tests. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --timeout 10000 +``` + +### Integration tests + +Integration tests (files ending in `.integrationTest.ts` or located in `extensions/`) are **not run** by `scripts/test.sh`. Use `scripts/test-integration.sh` (or `scripts/test-integration.bat`) instead. + +### Compilation requirement + +Tests run against compiled JavaScript output. Ensure the `VS Code - Build` watch task is running or that compilation has completed before running tests. Test failures caused by stale output are a common pitfall. diff --git a/.github/skills/update-screenshots/SKILL.md b/.github/skills/update-screenshots/SKILL.md index 46172cfee2d9b..294125273ef12 100644 --- a/.github/skills/update-screenshots/SKILL.md +++ b/.github/skills/update-screenshots/SKILL.md @@ -72,7 +72,16 @@ git add test/componentFixtures/.screenshots/baseline/ git commit -m "update screenshot baselines from CI" ``` -### 7. Verify +### 7. Push LFS objects before pushing + +Screenshot baselines are stored in Git LFS. The `git lfs pre-push` hook is not active in this repo (husky overwrites it), so LFS objects are NOT automatically uploaded on `git push`. You must push them manually before pushing the branch, otherwise the push will fail with `GH008: Your push referenced unknown Git LFS objects`. + +```bash +git lfs push --all origin +git push +``` + +### 8. Verify Confirm the baselines are updated by listing the files: diff --git a/.github/workflows/api-proposal-version-check.yml b/.github/workflows/api-proposal-version-check.yml new file mode 100644 index 0000000000000..ee082dee49f5b --- /dev/null +++ b/.github/workflows/api-proposal-version-check.yml @@ -0,0 +1,298 @@ +name: API Proposal Version Check + +on: + pull_request: + branches: + - main + - 'release/*' + paths: + - 'src/vscode-dts/vscode.proposed.*.d.ts' + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + actions: write + +concurrency: + group: api-proposal-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + check-version-changes: + name: Check API Proposal Version Changes + # Run on PR events, or on issue_comment if it's on a PR and contains the override command + if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/api-proposal-change-required') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR')) + runs-on: ubuntu-latest + steps: + - name: Get PR info + id: pr_info + uses: actions/github-script@v8 + with: + script: | + let prNumber, headSha, baseSha; + + if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + headSha = context.payload.pull_request.head.sha; + baseSha = context.payload.pull_request.base.sha; + } else { + // issue_comment event - need to fetch PR details + prNumber = context.payload.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + headSha = pr.head.sha; + baseSha = pr.base.sha; + } + + core.setOutput('number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('base_sha', baseSha); + + - name: Check for override comment + id: check_override + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + // Only accept overrides from trusted users (repo members/collaborators) + const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; + let overrideComment = null; + const untrustedOverrides = []; + + comments.forEach((comment, index) => { + const hasOverrideText = comment.body.includes('/api-proposal-change-required'); + const isTrusted = trustedAssociations.includes(comment.author_association); + console.log(`Comment ${index + 1}:`); + console.log(` Author: ${comment.user.login}`); + console.log(` Author association: ${comment.author_association}`); + console.log(` Created at: ${comment.created_at}`); + console.log(` Contains override command: ${hasOverrideText}`); + console.log(` Author is trusted: ${isTrusted}`); + console.log(` Would be valid override: ${hasOverrideText && isTrusted}`); + + if (hasOverrideText) { + if (isTrusted && !overrideComment) { + overrideComment = comment; + } else if (!isTrusted) { + untrustedOverrides.push(comment); + } + } + }); + + if (overrideComment) { + console.log(`✅ Override comment FOUND`); + console.log(` Comment ID: ${overrideComment.id}`); + console.log(` Author: ${overrideComment.user.login}`); + console.log(` Association: ${overrideComment.author_association}`); + console.log(` Created at: ${overrideComment.created_at}`); + core.setOutput('override_found', 'true'); + core.setOutput('override_user', overrideComment.user.login); + } else { + if (untrustedOverrides.length > 0) { + console.log(`⚠️ Found ${untrustedOverrides.length} override comment(s) from UNTRUSTED user(s):`); + untrustedOverrides.forEach((comment, index) => { + console.log(` Untrusted override ${index + 1}:`); + console.log(` Author: ${comment.user.login}`); + console.log(` Association: ${comment.author_association}`); + console.log(` Created at: ${comment.created_at}`); + console.log(` Comment ID: ${comment.id}`); + }); + console.log(` Trusted associations are: ${trustedAssociations.join(', ')}`); + } + console.log('❌ No valid override comment found'); + core.setOutput('override_found', 'false'); + } + + # If triggered by the override comment, re-run the failed workflow to update its status + # Only allow trusted users to trigger re-runs to prevent spam + - name: Re-run failed workflow on override + if: | + steps.check_override.outputs.override_found == 'true' && + github.event_name == 'issue_comment' && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + uses: actions/github-script@v8 + with: + script: | + const headSha = '${{ steps.pr_info.outputs.head_sha }}'; + console.log(`Override comment found by ${{ steps.check_override.outputs.override_user }}`); + console.log('API proposal version change has been acknowledged.'); + + // Find the failed workflow run for this PR's head SHA + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'api-proposal-version-check.yml', + head_sha: headSha, + status: 'completed', + per_page: 10 + }); + + // Find the most recent failed run + const failedRun = runs.workflow_runs.find(run => + run.conclusion === 'failure' && run.event === 'pull_request' + ); + + if (failedRun) { + console.log(`Re-running failed workflow run ${failedRun.id}`); + await github.rest.actions.reRunWorkflow({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: failedRun.id + }); + console.log('Workflow re-run triggered successfully'); + } else { + console.log('No failed pull_request workflow run found to re-run'); + // The check will pass on this run since override exists + } + + - name: Pass on override comment + if: steps.check_override.outputs.override_found == 'true' + run: | + echo "Override comment found by ${{ steps.check_override.outputs.override_user }}" + echo "API proposal version change has been acknowledged." + + # Only continue checking if no override found + - name: Checkout repository + if: steps.check_override.outputs.override_found != 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes + if: steps.check_override.outputs.override_found != 'true' + id: version_check + env: + HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }} + BASE_SHA: ${{ steps.pr_info.outputs.base_sha }} + run: | + set -e + + # Use merge-base to get accurate diff of what the PR actually changes + MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + echo "Merge base: $MERGE_BASE" + + # Get the list of changed proposed API files (diff against merge-base) + CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- 'src/vscode-dts/vscode.proposed.*.d.ts' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "No proposed API files changed" + echo "version_changed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Changed proposed API files:" + echo "$CHANGED_FILES" + + VERSION_CHANGED="false" + CHANGED_LIST="" + + for FILE in $CHANGED_FILES; do + # Check if file exists in head + if ! git cat-file -e "$HEAD_SHA:$FILE" 2>/dev/null; then + echo "File $FILE was deleted, skipping version check" + continue + fi + + # Get version from head (current PR) + HEAD_VERSION=$(git show "$HEAD_SHA:$FILE" | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + # Get version from merge-base (what the PR is based on) + BASE_VERSION=$(git show "$MERGE_BASE:$FILE" 2>/dev/null | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + echo "File: $FILE" + echo " Base version: ${BASE_VERSION:-'(none)'}" + echo " Head version: ${HEAD_VERSION:-'(none)'}" + + # Check if version was added or changed + if [ -n "$HEAD_VERSION" ] && [ "$HEAD_VERSION" != "$BASE_VERSION" ]; then + echo " -> Version changed!" + VERSION_CHANGED="true" + FILENAME=$(basename "$FILE") + if [ -n "$CHANGED_LIST" ]; then + CHANGED_LIST="$CHANGED_LIST, $FILENAME" + else + CHANGED_LIST="$FILENAME" + fi + fi + done + + echo "version_changed=$VERSION_CHANGED" >> $GITHUB_OUTPUT + echo "changed_files=$CHANGED_LIST" >> $GITHUB_OUTPUT + + - name: Post warning comment + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const changedFiles = '${{ steps.version_check.outputs.changed_files }}'; + + // Check if we already posted a warning comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const marker = ''; + const existingComment = comments.find(comment => + comment.body.includes(marker) + ); + + const body = `${marker} + ## ⚠️ API Proposal Version Change Detected + + The following proposed API files have version changes: **${changedFiles}** + + API proposal version changes should only be used when maintaining compatibility is not possible. Consider keeping the version as is and maintaining backward compatibility. + + **Any version changes must be adopted by the consuming extensions before the next insiders for the extension to work.** + + --- + + If the version change is required, comment \`/api-proposal-change-required\` to unblock this check and acknowledge that you will update any critical consuming extensions (Copilot Chat).`; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + console.log('Updated existing warning comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + console.log('Posted new warning comment'); + } + + - name: Fail if version changed without override + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + run: | + echo "::error::API proposal version changed in: ${{ steps.version_check.outputs.changed_files }}" + echo "To unblock, comment '/api-proposal-change-required' on the PR." + exit 1 diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml index 45d1ae55f623b..be9cf34d0777a 100644 --- a/.github/workflows/no-engineering-system-changes.yml +++ b/.github/workflows/no-engineering-system-changes.yml @@ -21,22 +21,52 @@ jobs: echo "engineering_systems_modified=false" >> $GITHUB_OUTPUT echo "No engineering systems were modified in this PR" fi + - name: Allow automated distro updates + id: distro_exception + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login == 'vs-code-engineering[bot]' }} + run: | + # Allow the vs-code-engineering bot ONLY when package.json is the + # sole changed file and the diff exclusively touches the "distro" field. + ONLY_PKG=$(jq -e '. == ["package.json"]' "$HOME/files.json" > /dev/null 2>&1 && echo true || echo false) + if [[ "$ONLY_PKG" != "true" ]]; then + echo "Bot modified files beyond package.json — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + DIFF=$(gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }}) || { + echo "Failed to fetch PR diff — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + exit 0 + } + CHANGED_LINES=$(echo "$DIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | wc -l) + DISTRO_LINES=$(echo "$DIFF" | grep -cE '^[+-][[:space:]]*"distro"[[:space:]]*:' || true) + + if [[ "$CHANGED_LINES" -eq 2 && "$DISTRO_LINES" -eq 2 ]]; then + echo "Distro-only update by bot — allowing" + echo "allowed=true" >> $GITHUB_OUTPUT + else + echo "Bot changed more than the distro field — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Prevent Copilot from modifying engineering systems - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login == 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'Copilot' }} run: | echo "Copilot is not allowed to modify .github/workflows, build folder files, or package.json files." echo "If you need to update engineering systems, please do so manually or through authorized means." exit 1 - uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 id: get_permissions - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} with: route: GET /repos/microsoft/vscode/collaborators/${{ github.event.pull_request.user.login }}/permission env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set control output variable id: control - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} run: | echo "user: ${{ github.event.pull_request.user.login }}" echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}" @@ -44,7 +74,7 @@ jobs: echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}" echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT - name: Check for engineering system changes - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.control.outputs.should_run == 'true' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && steps.control.outputs.should_run == 'true' }} run: | echo "Changes to .github/workflows/, build/ folder files, or package.json files aren't allowed in PRs." exit 1 diff --git a/.github/workflows/no-package-lock-changes.yml b/.github/workflows/no-package-lock-changes.yml deleted file mode 100644 index 04ea8a43a8088..0000000000000 --- a/.github/workflows/no-package-lock-changes.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Prevent package-lock.json changes in PRs - -on: pull_request -permissions: {} - -jobs: - main: - name: Prevent package-lock.json changes in PRs - runs-on: ubuntu-latest - steps: - - name: Get file changes - uses: trilom/file-changes-action@ce38c8ce2459ca3c303415eec8cb0409857b4272 - id: file_changes - - name: Check if lockfiles were modified - id: lockfile_check - run: | - if cat $HOME/files.json | jq -e 'any(test("package-lock\\.json$|Cargo\\.lock$"))' > /dev/null; then - echo "lockfiles_modified=true" >> $GITHUB_OUTPUT - echo "Lockfiles were modified in this PR" - else - echo "lockfiles_modified=false" >> $GITHUB_OUTPUT - echo "No lockfiles were modified in this PR" - fi - - name: Prevent Copilot from modifying lockfiles - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login == 'Copilot' }} - run: | - echo "Copilot is not allowed to modify package-lock.json or Cargo.lock files." - echo "If you need to update dependencies, please do so manually or through authorized means." - exit 1 - - uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 - id: get_permissions - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} - with: - route: GET /repos/microsoft/vscode/collaborators/{username}/permission - username: ${{ github.event.pull_request.user.login }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Set control output variable - id: control - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} - run: | - echo "user: ${{ github.event.pull_request.user.login }}" - echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}" - echo "is dependabot: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}" - echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}" - echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT - - name: Check for lockfile changes - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && steps.control.outputs.should_run == 'true' }} - run: | - echo "Changes to package-lock.json/Cargo.lock files aren't allowed in PRs." - exit 1 diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c876d2a3782be..56cd6e6ba2eb4 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -212,7 +212,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -223,7 +223,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -232,7 +232,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index df6ab20e586b5..9d0fb76b43653 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -42,7 +42,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults @@ -258,7 +260,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +271,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -278,7 +280,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index bd4a62d42fa26..2bde317b4806a 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -249,7 +249,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -260,7 +260,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b988d19f49ea6..4d5040fb6009e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,7 +10,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: {} +permissions: + contents: read env: VSCODE_QUALITY: 'oss' @@ -77,7 +78,7 @@ jobs: working-directory: build - name: Compile & Hygiene - run: npm exec -- npm-run-all2 -lp core-ci extensions-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts + run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index a45f8d38133bb..01f186a1c8146 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -1,4 +1,4 @@ -name: Screenshot Tests +name: Checking Component Screenshots on: push: @@ -10,8 +10,6 @@ on: permissions: contents: read - pull-requests: write - checks: write statuses: write concurrency: @@ -20,15 +18,16 @@ concurrency: jobs: screenshots: + name: Checking Component Screenshots runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc @@ -55,12 +54,12 @@ jobs: run: npx playwright install chromium - name: Capture screenshots - run: npx component-explorer screenshot --project ./test/componentFixtures/component-explorer.json + run: ./node_modules/.bin/component-explorer screenshot --project ./test/componentFixtures/component-explorer.json - name: Compare screenshots id: compare run: | - npx component-explorer screenshot:compare \ + ./node_modules/.bin/component-explorer screenshot:compare \ --project ./test/componentFixtures \ --report ./test/componentFixtures/.screenshots/report continue-on-error: true @@ -74,14 +73,14 @@ jobs: fi - name: Upload explorer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: component-explorer path: /tmp/explorer-artifact/ - name: Upload screenshot report if: steps.compare.outcome == 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: screenshot-diff path: | @@ -93,41 +92,35 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | REPORT="test/componentFixtures/.screenshots/report/report.json" + STATE="success" if [ -f "$REPORT" ]; then CHANGED=$(node -e "const r = require('./$REPORT'); console.log(r.summary.added + r.summary.removed + r.summary.changed)") - TITLE="${CHANGED} screenshots changed" + TITLE="⚠ ${CHANGED} screenshots changed" + BLOCKS_CI=$(node -e " + const r = require('./$REPORT'); + const blocking = Object.entries(r.fixtures).filter(([, f]) => + f.status !== 'unchanged' && (f.labels || []).includes('blocks-ci') + ); + if (blocking.length > 0) { + console.log(blocking.map(([name]) => name).join(', ')); + } + ") + if [ -n "$BLOCKS_CI" ]; then + STATE="failure" + TITLE="❌ ${CHANGED} screenshots changed (blocks CI: ${BLOCKS_CI})" + fi else - TITLE="Screenshots match" + TITLE="✅ Screenshots match" fi SHA="${{ github.event.pull_request.head.sha || github.sha }}" - CHECK_RUN_ID=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \ - --jq '.check_runs[] | select(.name == "screenshots") | .id') + DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json&search=changed" - DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json" - - if [ -n "$CHECK_RUN_ID" ]; then - gh api "repos/${{ github.repository }}/check-runs/$CHECK_RUN_ID" \ - -X PATCH --input - <> $GITHUB_STEP_SUMMARY - else - echo "## Screenshots ✅" >> $GITHUB_STEP_SUMMARY - echo "No visual changes detected." >> $GITHUB_STEP_SUMMARY - fi - # - name: Post PR comment # if: github.event_name == 'pull_request' # env: diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml new file mode 100644 index 0000000000000..47ef5fb46bd39 --- /dev/null +++ b/.github/workflows/sessions-e2e.yml @@ -0,0 +1,66 @@ +name: Sessions E2E Tests + +# on: +# pull_request: +# branches: +# - main +# - 'release/*' +# paths: +# - 'src/vs/sessions/**' +# - 'scripts/code-sessions-web.*' + +permissions: + contents: read + +concurrency: + group: sessions-e2e-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + sessions-e2e: + name: Sessions E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Install build tools + run: sudo apt update -y && sudo apt install -y build-essential pkg-config libx11-dev libx11-xcb-dev libxkbfile-dev libnotify-bin libkrb5-dev xvfb + + - name: Install dependencies + run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install build dependencies + run: npm ci + working-directory: build + + - name: Transpile sources + run: npm run transpile-client + + - name: Install E2E test dependencies + run: npm ci + working-directory: src/vs/sessions/test/e2e + + - name: Install Playwright browsers + run: npx playwright install chromium + + - name: Run Sessions E2E tests + run: xvfb-run npm test + working-directory: src/vs/sessions/test/e2e + + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: sessions-e2e-failures + path: src/vs/sessions/test/e2e/out/failure-*.png + retention-days: 7 diff --git a/.gitignore b/.gitignore index 9a9fdcadff97a..65cb193716801 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,17 @@ test-output.json test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ dist +.playwright-cli +.claude/ +.agents/agents/*.local.md +.github/agents/*.local.md +.agents/agents/*.local.agent.md +.github/agents/*.local.agent.md +.agents/hooks/*.local.json +.github/hooks/*.local.json +.agents/instructions/*.local.instructions.md +.github/instructions/*.local.instructions.md +.agents/prompts/*.local.prompt.md +.github/prompts/*.local.prompt.md +.agents/skills/.local/ +.github/skills/.local/ diff --git a/.mailmap b/.mailmap index 4834393cff7ac..5bd99619330ac 100644 --- a/.mailmap +++ b/.mailmap @@ -1,4 +1,3 @@ -Daniel Imms Daniel Imms Raymond Zhao Tyler Leonhardt Tyler Leonhardt João Moreno João Moreno diff --git a/.npmrc b/.npmrc index b07eade64d573..d0d75ed8d9961 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.6.0" -ms_build_id="13330601" +target="39.8.3" +ms_build_id="13620978" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.nvmrc b/.nvmrc index 85e502778f623..32a2d7bd80d19 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.22.0 +22.22.1 diff --git a/.vscode/extensions/vscode-extras/package-lock.json b/.vscode/extensions/vscode-extras/package-lock.json new file mode 100644 index 0000000000000..3268c74682804 --- /dev/null +++ b/.vscode/extensions/vscode-extras/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "vscode-extras", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-extras", + "version": "0.0.1", + "license": "MIT", + "engines": { + "vscode": "^1.88.0" + } + } + } +} diff --git a/.vscode/extensions/vscode-extras/package.json b/.vscode/extensions/vscode-extras/package.json new file mode 100644 index 0000000000000..c773d5923c322 --- /dev/null +++ b/.vscode/extensions/vscode-extras/package.json @@ -0,0 +1,38 @@ +{ + "name": "vscode-extras", + "displayName": "VS Code Extras", + "description": "Extra utility features for the VS Code selfhost workspace", + "engines": { + "vscode": "^1.88.0" + }, + "version": "0.0.1", + "publisher": "ms-vscode", + "categories": [ + "Other" + ], + "activationEvents": [ + "workspaceContains:src/vscode-dts/vscode.d.ts" + ], + "main": "./out/extension.js", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/vscode.git" + }, + "license": "MIT", + "scripts": { + "compile": "gulp compile-extension:vscode-extras", + "watch": "gulp watch-extension:vscode-extras" + }, + "contributes": { + "configuration": { + "title": "VS Code Extras", + "properties": { + "vscode-extras.npmUpToDateFeature.enabled": { + "type": "boolean", + "default": true, + "description": "Show a status bar warning when npm dependencies are out of date." + } + } + } + } +} diff --git a/.vscode/extensions/vscode-extras/src/extension.ts b/.vscode/extensions/vscode-extras/src/extension.ts new file mode 100644 index 0000000000000..675bfe9177549 --- /dev/null +++ b/.vscode/extensions/vscode-extras/src/extension.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { NpmUpToDateFeature } from './npmUpToDateFeature'; + +export class Extension extends vscode.Disposable { + private readonly _output: vscode.LogOutputChannel; + private _npmFeature: NpmUpToDateFeature | undefined; + + constructor(_context: vscode.ExtensionContext) { + const disposables: vscode.Disposable[] = []; + super(() => disposables.forEach(d => d.dispose())); + + this._output = vscode.window.createOutputChannel('VS Code Extras', { log: true }); + disposables.push(this._output); + + this._updateNpmFeature(); + + disposables.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('vscode-extras.npmUpToDateFeature.enabled')) { + this._updateNpmFeature(); + } + }) + ); + } + + private _updateNpmFeature(): void { + const enabled = vscode.workspace.getConfiguration('vscode-extras').get('npmUpToDateFeature.enabled', true); + if (enabled && !this._npmFeature) { + this._npmFeature = new NpmUpToDateFeature(this._output); + } else if (!enabled && this._npmFeature) { + this._npmFeature.dispose(); + this._npmFeature = undefined; + } + } +} + +let extension: Extension | undefined; + +export function activate(context: vscode.ExtensionContext) { + extension = new Extension(context); + context.subscriptions.push(extension); +} + +export function deactivate() { + extension = undefined; +} diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts new file mode 100644 index 0000000000000..f21e36604fbb2 --- /dev/null +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +interface FileHashes { + readonly [relativePath: string]: string; +} + +interface PostinstallState { + readonly nodeVersion: string; + readonly fileHashes: FileHashes; +} + +interface InstallState { + readonly root: string; + readonly stateContentsFile: string; + readonly current: PostinstallState; + readonly saved: PostinstallState | undefined; + readonly files: readonly string[]; +} + +export class NpmUpToDateFeature extends vscode.Disposable { + private readonly _statusBarItem: vscode.StatusBarItem; + private readonly _disposables: vscode.Disposable[] = []; + private _watchers: fs.FSWatcher[] = []; + private _terminal: vscode.Terminal | undefined; + private _stateContentsFile: string | undefined; + private _root: string | undefined; + + private static readonly _scheme = 'npm-dep-state'; + + constructor(private readonly _output: vscode.LogOutputChannel) { + const disposables: vscode.Disposable[] = []; + super(() => { + disposables.forEach(d => d.dispose()); + for (const w of this._watchers) { + w.close(); + } + }); + this._disposables = disposables; + + this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10000); + this._statusBarItem.name = 'npm Install State'; + this._statusBarItem.text = '$(warning) node_modules is stale - run npm i'; + this._statusBarItem.tooltip = 'Dependencies are out of date. Click to run npm install.'; + this._statusBarItem.command = 'vscode-extras.runNpmInstall'; + this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this._disposables.push(this._statusBarItem); + + this._disposables.push( + vscode.workspace.registerTextDocumentContentProvider(NpmUpToDateFeature._scheme, { + provideTextDocumentContent: (uri) => { + const params = new URLSearchParams(uri.query); + const source = params.get('source'); + const file = uri.path.slice(1); // strip leading / + if (source === 'saved') { + return this._readSavedContent(file); + } + return this._readCurrentContent(file); + } + }) + ); + + this._disposables.push( + vscode.commands.registerCommand('vscode-extras.runNpmInstall', () => this._runNpmInstall()) + ); + + this._disposables.push( + vscode.commands.registerCommand('vscode-extras.showDependencyDiff', (file: string) => this._showDiff(file)) + ); + + this._disposables.push( + vscode.window.onDidCloseTerminal(t => { + if (t === this._terminal) { + this._terminal = undefined; + this._check(); + } + }) + ); + + this._check(); + } + + private _runNpmInstall(): void { + if (this._terminal) { + this._terminal.dispose(); + } + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri; + if (!workspaceRoot) { + return; + } + this._terminal = vscode.window.createTerminal({ name: 'npm install', cwd: workspaceRoot }); + this._terminal.sendText('node build/npm/fast-install.ts --force'); + this._terminal.show(); + + this._statusBarItem.text = '$(loading~spin) npm i'; + this._statusBarItem.tooltip = 'npm install is running...'; + this._statusBarItem.backgroundColor = undefined; + this._statusBarItem.command = 'vscode-extras.runNpmInstall'; + } + + private _queryState(): InstallState | undefined { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) { + return undefined; + } + try { + const script = path.join(workspaceRoot, 'build', 'npm', 'installStateHash.ts'); + const output = cp.execFileSync(process.execPath, [script, '--ignore-node-version'], { + cwd: workspaceRoot, + timeout: 10_000, + encoding: 'utf8', + }); + const parsed = JSON.parse(output.trim()); + this._output.trace('raw output:', output.trim()); + return parsed; + } catch (e) { + this._output.error('_queryState error:', e as any); + return undefined; + } + } + + private _check(): void { + const state = this._queryState(); + this._output.trace('state:', JSON.stringify(state, null, 2)); + if (!state) { + this._output.trace('no state, hiding'); + this._statusBarItem.hide(); + return; + } + + this._stateContentsFile = state.stateContentsFile; + this._root = state.root; + this._setupWatcher(state); + + const changedFiles = this._getChangedFiles(state); + this._output.trace('changedFiles:', JSON.stringify(changedFiles)); + + if (changedFiles.length === 0) { + this._statusBarItem.hide(); + } else { + this._statusBarItem.text = '$(warning) node_modules is stale - run npm i'; + const tooltip = new vscode.MarkdownString(); + tooltip.isTrusted = true; + tooltip.supportHtml = true; + tooltip.appendMarkdown('**Dependencies are out of date.** Click to run npm install.\n\nChanged files:\n\n'); + for (const entry of changedFiles) { + if (entry.isFile) { + const args = encodeURIComponent(JSON.stringify(entry.label)); + tooltip.appendMarkdown(`- [${entry.label}](command:vscode-extras.showDependencyDiff?${args})\n`); + } else { + tooltip.appendMarkdown(`- ${entry.label}\n`); + } + } + this._statusBarItem.tooltip = tooltip; + this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this._statusBarItem.show(); + } + } + + private _showDiff(file: string): void { + const cacheBuster = Date.now().toString(); + const savedUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'saved', t: cacheBuster }).toString(), + }); + const currentUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'current', t: cacheBuster }).toString(), + }); + + vscode.commands.executeCommand('vscode.diff', savedUri, currentUri, `${file} (last install ↔ current)`); + } + + private _readSavedContent(file: string): string { + if (!this._stateContentsFile) { + return ''; + } + try { + const contents: Record = JSON.parse(fs.readFileSync(this._stateContentsFile, 'utf8')); + return contents[file] ?? ''; + } catch { + return ''; + } + } + + private _readCurrentContent(file: string): string { + if (!this._root) { + return ''; + } + try { + const script = path.join(this._root, 'build', 'npm', 'installStateHash.ts'); + return cp.execFileSync(process.execPath, [script, '--normalize-file', path.join(this._root, file)], { + cwd: this._root, + timeout: 10_000, + encoding: 'utf8', + }); + } catch { + return ''; + } + } + + private _getChangedFiles(state: InstallState): { readonly label: string; readonly isFile: boolean }[] { + if (!state.saved) { + return [{ label: '(no postinstall state found)', isFile: false }]; + } + const changed: { readonly label: string; readonly isFile: boolean }[] = []; + if (state.saved.nodeVersion !== state.current.nodeVersion) { + changed.push({ label: `Node.js version (${state.saved.nodeVersion} → ${state.current.nodeVersion})`, isFile: false }); + } + const allKeys = new Set([...Object.keys(state.current.fileHashes), ...Object.keys(state.saved.fileHashes)]); + for (const key of allKeys) { + if (state.current.fileHashes[key] !== state.saved.fileHashes[key]) { + changed.push({ label: key, isFile: true }); + } + } + return changed; + } + + private _setupWatcher(state: InstallState): void { + for (const w of this._watchers) { + w.close(); + } + this._watchers = []; + + let debounceTimer: ReturnType | undefined; + const scheduleCheck = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => this._check(), 500); + }; + + for (const file of state.files) { + try { + const watcher = fs.watch(file, scheduleCheck); + this._watchers.push(watcher); + } catch { + // file may not exist yet + } + } + } +} diff --git a/.vscode/extensions/vscode-extras/tsconfig.json b/.vscode/extensions/vscode-extras/tsconfig.json new file mode 100644 index 0000000000000..9133c3bbf4b87 --- /dev/null +++ b/.vscode/extensions/vscode-extras/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../extensions/tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./out", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*", + "../../../src/vscode-dts/vscode.d.ts" + ] +} diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts index 296ed1e9f12be..09e9a2af6d2b5 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts @@ -545,6 +545,9 @@ export class SourceMapStore { } } + if (/^[a-zA-Z]:/.test(source) || source.startsWith('/')) { + return vscode.Uri.file(source); + } return vscode.Uri.parse(source); } diff --git a/.vscode/launch.json b/.vscode/launch.json index d116d2c003389..24c1abde456ea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,6 +52,15 @@ "${workspaceFolder}/out/**/*.js" ] }, + { + "type": "node", + "request": "attach", + "name": "Attach to Agent Host Process", + "port": 5878, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ] + }, { "type": "node", "request": "attach", @@ -278,6 +287,51 @@ "hidden": true, }, }, + { + "type": "chrome", + "request": "launch", + "name": "Launch VS Sessions Internal", + "windows": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.bat" + }, + "osx": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "linux": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "port": 9222, + "timeout": 0, + "env": { + "VSCODE_EXTHOST_WILL_SEND_SOCKET": null, + "VSCODE_SKIP_PRELAUNCH": "1", + "VSCODE_DEV_DEBUG_OBSERVABLES": "1", + }, + "cleanUp": "wholeBrowser", + "killBehavior": "polite", + "runtimeArgs": [ + "--inspect-brk=5875", + "--no-cached-data", + "--crash-reporter-directory=${workspaceFolder}/.profile-oss/crashes", + // for general runtime freezes: https://github.com/microsoft/vscode/issues/127861#issuecomment-904144910 + "--disable-features=CalculateNativeWinOcclusion", + "--disable-extension=vscode.vscode-api-tests", + "--sessions" + ], + "userDataDir": "${userHome}/.vscode-oss-sessions-dev", + "webRoot": "${workspaceFolder}", + "cascadeTerminateToConfigurations": [ + "Attach to Extension Host" + ], + "pauseForSourceMap": false, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "browserLaunchLocation": "workspace", + "presentation": { + "hidden": true, + }, + }, { // To debug observables you also need the extension "ms-vscode.debug-value-editor" "type": "chrome", @@ -603,11 +657,21 @@ } }, { - "name": "Component Explorer", + "name": "Component Explorer (Edge)", "type": "msedge", - "port": 9230, "request": "launch", - "url": "http://localhost:5337/___explorer", + "url": "${taskVar:componentExplorerUrl}", + "preLaunchTask": "Launch Component Explorer", + "presentation": { + "group": "1_component_explorer", + "order": 4 + } + }, + { + "name": "Component Explorer (Chrome)", + "type": "chrome", + "request": "launch", + "url": "${taskVar:componentExplorerUrl}", "preLaunchTask": "Launch Component Explorer", "presentation": { "group": "1_component_explorer", @@ -646,6 +710,22 @@ "Attach to Main Process", "Attach to Extension Host", "Attach to Shared Process", + "Attach to Agent Host Process" + ], + "preLaunchTask": "Ensure Prelaunch Dependencies", + "presentation": { + "group": "0_vscode", + "order": 1 + } + }, + { + "name": "VS Sessions", + "stopAll": true, + "configurations": [ + "Launch VS Sessions Internal", + "Attach to Main Process", + "Attach to Extension Host", + "Attach to Shared Process", ], "preLaunchTask": "Ensure Prelaunch Dependencies", "presentation": { diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index d3f716f5749cb..c039ae27ce854 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,82 +7,32 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"February 2026\"" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, "language": "markdown", - "value": "# Preparation" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Open Pull Requests on the Milestone" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:pr is:open" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Unverified Older Insiders-Released Issues" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft -$MILESTONE is:issue is:closed reason:completed label:bug label:insiders-released -label:verified -label:*duplicate -label:*as-designed -label:z-author-verified -label:on-testplan -label:error-telemetry" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Unverified Older Insiders-Released Feature Requests" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft -$MILESTONE is:issue is:closed reason:completed label:feature-request label:insiders-released -label:on-testplan -label:verified -label:*duplicate -label:error-telemetry" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Open Issues on the Milestone" + "value": "## Prep: Open PRs and Issues" }, { "kind": 2, "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:open -label:iteration-plan -label:endgame-plan -label:testplan-item" + "value": "org:microsoft $MILESTONE is:issue is:open -label:iteration-plan -label:endgame-plan -label:testplan-item\norg:microsoft $MILESTONE is:pr is:open" }, { "kind": 1, "language": "markdown", - "value": "## Feature Requests Missing Labels" + "value": "## Verification: Missing Steps" }, { "kind": 2, "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed label:feature-request -label:verification-needed -label:on-testplan -label:verified -label:*duplicate" + "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed sort:updated-asc label:verification-steps-needed -label:verified -label:on-testplan -label:*duplicate -label:duplicate -label:invalid -label:*as-designed -label:error-telemetry -label:z-author-verified -label:unreleased -label:*not-reproducible" }, { "kind": 1, "language": "markdown", - "value": "## Open Test Plan Items without milestone" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:open label:testplan-item no:milestone" - }, - { - "kind": 1, - "language": "markdown", - "value": "# Testing" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Test Plan Items" + "value": "## Testing & Verification" }, { "kind": 2, @@ -92,66 +42,11 @@ { "kind": 1, "language": "markdown", - "value": "## Verification Needed" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed label:verification-needed -label:verified -label:on-testplan" - }, - { - "kind": 1, - "language": "markdown", - "value": "# Verification" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Verifiable Fixes" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed sort:updated-asc label:bug -label:verified -label:on-testplan -label:*duplicate -label:duplicate -label:invalid -label:*as-designed -label:error-telemetry -label:verification-steps-needed -label:z-author-verified -label:unreleased -label:*not-reproducible -label:*out-of-scope" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Verifiable Fixes Missing Steps" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed sort:updated-asc label:bug label:verification-steps-needed -label:verified -label:on-testplan -label:*duplicate -label:duplicate -label:invalid -label:*as-designed -label:error-telemetry -label:z-author-verified -label:unreleased -label:*not-reproducible" - }, - { - "kind": 1, - "language": "markdown", - "value": "## Unreleased Fixes" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed sort:updated-asc label:bug -label:verified -label:on-testplan -label:*duplicate -label:duplicate -label:invalid -label:*as-designed -label:error-telemetry -label:verification-steps-needed -label:z-author-verified label:unreleased -label:*not-reproducible" - }, - { - "kind": 1, - "language": "markdown", - "value": "## All Unverified Fixes" - }, - { - "kind": 2, - "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:closed reason:completed sort:updated-asc label:bug -label:verified -label:on-testplan -label:*duplicate -label:duplicate -label:invalid -label:*as-designed -label:error-telemetry -label:z-author-verified -label:*not-reproducible" - }, - { - "kind": 1, - "language": "markdown", - "value": "# Candidates" + "value": "These are bugs we created and closed after running the TPI tool. They need to be `verified` manually" }, { "kind": 2, "language": "github-issues", - "value": "org:microsoft $MILESTONE is:issue is:open label:candidate" + "value": "org:microsoft $MILESTONE is:issue is:closed label:bug reason:completed -label:verified created:>=$TPI_CREATION" } ] \ No newline at end of file diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index b58910ad675e5..8ee3e6cbe3bb7 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,12 +7,12 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"February 2026\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, "language": "github-issues", - "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:Tyriar -author:weinand -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dineshc-msft -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99 -author:rwoll -author:lostintangent -author:jukasper -author:zhichli" + "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99 -author:rwoll -author:lostintangent -author:jukasper -author:zhichli" }, { "kind": 1, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index b6c82fff3590b..e5c0bd60fbb1d 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"February 2026\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"1.114.0\"\n" }, { "kind": 1, diff --git a/.vscode/notebooks/verification.github-issues b/.vscode/notebooks/verification.github-issues index 1c7e9dc184378..84e36a975d52e 100644 --- a/.vscode/notebooks/verification.github-issues +++ b/.vscode/notebooks/verification.github-issues @@ -32,7 +32,7 @@ { "kind": 2, "language": "github-issues", - "value": "$repos $milestone is:closed reason:completed -assignee:@me label:bug -label:verified -label:*duplicate -author:@me -assignee:@me label:bug -label:verified -author:@me -author:aeschli -author:alexdima -author:alexr00 -author:bpasero -author:chrisdias -author:chrmarti -author:connor4312 -author:dbaeumer -author:deepak1556 -author:eamodio -author:egamma -author:gregvanl -author:isidorn -author:JacksonKearl -author:joaomoreno -author:jrieken -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:RMacfarlane -author:roblourens -author:sana-ajani -author:sandy081 -author:sbatten -author:Tyriar -author:weinand -author:rzhao271 -author:kieferrm -author:TylerLeonhardt -author:bamurtaugh -author:hediet -author:joyceerhl -author:rchiodo" + "value": "$repos $milestone is:closed reason:completed -assignee:@me label:bug -label:verified -label:*duplicate -author:@me -assignee:@me label:bug -label:verified -author:@me -author:aeschli -author:alexdima -author:alexr00 -author:bpasero -author:chrisdias -author:chrmarti -author:connor4312 -author:dbaeumer -author:deepak1556 -author:eamodio -author:egamma -author:gregvanl -author:isidorn -author:JacksonKearl -author:joaomoreno -author:jrieken -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:RMacfarlane -author:roblourens -author:sana-ajani -author:sandy081 -author:sbatten -author:weinand -author:rzhao271 -author:kieferrm -author:TylerLeonhardt -author:bamurtaugh -author:hediet -author:joyceerhl -author:rchiodo" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index 74343459e02ef..99f937bdf9dcb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,6 @@ "chat.tools.edits.autoApprove": { ".github/skills/azure-pipelines/azure-pipeline.ts": false }, - "chat.viewSessions.enabled": true, "chat.editing.explainChanges.enabled": true, // --- Editor --- "editor.insertSpaces": false, @@ -72,6 +71,7 @@ "extensions/terminal-suggest/src/completions/upstream/**": true, "test/smoke/out/**": true, "test/automation/out/**": true, + "src/vs/platform/agentHost/common/state/protocol/**": true, "test/integration/browser/out/**": true, // "src/vs/sessions/**": true }, @@ -209,4 +209,9 @@ "azureMcp.serverMode": "all", "azureMcp.readOnly": true, "debug.breakpointsView.presentation": "tree", + "chat.agentSkillsLocations": { + ".github/skills/.local": true, + ".agents/skills/.local": true, + ".claude/skills/.local": true, + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c330df2edecc9..09ba766f0b201 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,8 +225,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inSessions": true + "problemMatcher": [] }, { "label": "Run Dev Sessions", @@ -236,8 +235,29 @@ "command": ".\\scripts\\code.bat" }, "args": [ - "--sessions" + "--sessions", + "--user-data-dir=${userHome}/.vscode-oss-sessions-dev", + "--extensions-dir=${userHome}/.vscode-oss-sessions-dev/extensions" ], + "problemMatcher": [] + }, + { + "label": "Transpile Client", + "type": "npm", + "script": "transpile-client", + "problemMatcher": [] + }, + { + "label": "Run and Compile Sessions - OSS", + "dependsOn": ["Transpile Client", "Run Dev Sessions"], + "dependsOrder": "sequence", + "inSessions": true, + "problemMatcher": [] + }, + { + "label": "Run and Compile Code - OSS", + "dependsOn": ["Transpile Client", "Run Dev"], + "dependsOrder": "sequence", "inSessions": true, "problemMatcher": [] }, @@ -375,9 +395,46 @@ { "label": "Launch Component Explorer", "type": "shell", - "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json", + "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json -vv --kill-if-running", "isBackground": true, - "problemMatcher": [] + "problemMatcher": { + "owner": "component-explorer", + "fileLocation": "absolute", + "pattern": { + "regexp": "^\\s*at\\s+(.+?):(\\d+):(\\d+)\\s*$", + "file": 1, + "line": 2, + "column": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*Setting up sessions.*", + "endsPattern": " current: (?.*) \\(current\\)" + } + } + }, + { + "label": "Install & Watch", + "type": "shell", + "command": "npm ci && npm run watch", + "windows": { + "command": "cmd /c \"npm ci && npm run watch\"" + }, + "inSessions": true, + "runOptions": { + "runOn": "worktreeCreated" + } + }, + { + "label": "Echo E2E Status", + "type": "shell", + "command": "pwsh", + "args": [ + "-NoProfile", + "-Command", + "Write-Output \"134 passed, 0 failed, 1 skipped, 135 total\"; Start-Sleep -Seconds 2; Write-Output \"[PASS] E2E Tests\"; Write-Output \"Watching for changes...\"" + ], + "isBackground": false } ] } diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 896b59001d616..c4883fd0ad036 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -524,6 +524,580 @@ Title to copyright in this work will at all times remain with copyright holders. --------------------------------------------------------- +dompurify 3.2.7 - Apache 2.0 +https://github.com/cure53/DOMPurify + +DOMPurify +Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53 + +DOMPurify is free software; you can redistribute it and/or modify it under the +terms of either: + +a) the Apache License Version 2.0, or +b) the Mozilla Public License Version 2.0 + +----------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +----------------------------------------------------------------------------- +Mozilla Public License, version 2.0 + +1. Definitions + +1.1. "Contributor" + + means each individual or legal entity that creates, contributes to the + creation of, or owns Covered Software. + +1.2. "Contributor Version" + + means the combination of the Contributions of others (if any) used by a + Contributor and that particular Contributor’s Contribution. + +1.3. "Contribution" + + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + + means Source Code Form to which the initial Contributor has attached the + notice in Exhibit A, the Executable Form of such Source Code Form, and + Modifications of such Source Code Form, in each case including portions + thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + a. that the initial Contributor has attached the notice described in + Exhibit B to the Covered Software; or + + b. that the Covered Software was made available under the terms of version + 1.1 or earlier of the License, but not also under the terms of a + Secondary License. + +1.6. "Executable Form" + + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + + means a work that combines Covered Software with other material, in a separate + file or files, that is not Covered Software. + +1.8. "License" + + means this document. + +1.9. "Licensable" + + means having the right to grant, to the maximum extent possible, whether at the + time of the initial grant or subsequently, any and all of the rights conveyed by + this License. + +1.10. "Modifications" + + means any of the following: + + a. any file in Source Code Form that results from an addition to, deletion + from, or modification of the contents of Covered Software; or + + b. any new file in Source Code Form that contains any Covered Software. + +1.11. "Patent Claims" of a Contributor + + means any patent claim(s), including without limitation, method, process, + and apparatus claims, in any patent Licensable by such Contributor that + would be infringed, but for the grant of the License, by the making, + using, selling, offering for sale, having made, import, or transfer of + either its Contributions or its Contributor Version. + +1.12. "Secondary License" + + means either the GNU General Public License, Version 2.0, the GNU Lesser + General Public License, Version 2.1, the GNU Affero General Public + License, Version 3.0, or any later versions of those licenses. + +1.13. "Source Code Form" + + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that controls, is + controlled by, or is under common control with You. For purposes of this + definition, "control" means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the + outstanding shares or beneficial ownership of such entity. + + +2. License Grants and Conditions + +2.1. Grants + + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + a. under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or as + part of a Larger Work; and + + b. under Patent Claims of such Contributor to make, use, sell, offer for + sale, have made, import, and otherwise transfer either its Contributions + or its Contributor Version. + +2.2. Effective Date + + The licenses granted in Section 2.1 with respect to any Contribution become + effective for each Contribution on the date the Contributor first distributes + such Contribution. + +2.3. Limitations on Grant Scope + + The licenses granted in this Section 2 are the only rights granted under this + License. No additional rights or licenses will be implied from the distribution + or licensing of Covered Software under this License. Notwithstanding Section + 2.1(b) above, no patent license is granted by a Contributor: + + a. for any code that a Contributor has removed from Covered Software; or + + b. for infringements caused by: (i) Your and any other third party’s + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + + c. under Patent Claims infringed by Covered Software in the absence of its + Contributions. + + This License does not grant any rights in the trademarks, service marks, or + logos of any Contributor (except as may be necessary to comply with the + notice requirements in Section 3.4). + +2.4. Subsequent Licenses + + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this License + (see Section 10.2) or under the terms of a Secondary License (if permitted + under the terms of Section 3.3). + +2.5. Representation + + Each Contributor represents that the Contributor believes its Contributions + are its original creation(s) or it has sufficient rights to grant the + rights to its Contributions conveyed by this License. + +2.6. Fair Use + + This License is not intended to limit any rights You have under applicable + copyright doctrines of fair use, fair dealing, or other equivalents. + +2.7. Conditions + + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in + Section 2.1. + + +3. Responsibilities + +3.1. Distribution of Source Form + + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under the + terms of this License. You must inform recipients that the Source Code Form + of the Covered Software is governed by the terms of this License, and how + they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form. + +3.2. Distribution of Executable Form + + If You distribute Covered Software in Executable Form then: + + a. such Covered Software must also be made available in Source Code Form, + as described in Section 3.1, and You must inform recipients of the + Executable Form how they can obtain a copy of such Source Code Form by + reasonable means in a timely manner, at a charge no more than the cost + of distribution to the recipient; and + + b. You may distribute such Executable Form under the terms of this License, + or sublicense it under different terms, provided that the license for + the Executable Form does not attempt to limit or alter the recipients’ + rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for the + Covered Software. If the Larger Work is a combination of Covered Software + with a work governed by one or more Secondary Licenses, and the Covered + Software is not Incompatible With Secondary Licenses, this License permits + You to additionally distribute such Covered Software under the terms of + such Secondary License(s), so that the recipient of the Larger Work may, at + their option, further distribute the Covered Software under the terms of + either this License or such Secondary License(s). + +3.4. Notices + + You may not remove or alter the substance of any license notices (including + copyright notices, patent notices, disclaimers of warranty, or limitations + of liability) contained within the Source Code Form of the Covered + Software, except that You may alter any license notices to the extent + required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on behalf + of any Contributor. You must make it absolutely clear that any such + warranty, support, indemnity, or liability obligation is offered by You + alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + + If it is impossible for You to comply with any of the terms of this License + with respect to some or all of the Covered Software due to statute, judicial + order, or regulation then You must: (a) comply with the terms of this License + to the maximum extent possible; and (b) describe the limitations and the code + they affect. Such description must be placed in a text file included with all + distributions of the Covered Software under this License. Except to the + extent prohibited by statute or regulation, such description must be + sufficiently detailed for a recipient of ordinary skill to be able to + understand it. + +5. Termination + +5.1. The rights granted under this License will terminate automatically if You + fail to comply with any of its terms. However, if You become compliant, + then the rights granted under this License from a particular Contributor + are reinstated (a) provisionally, unless and until such Contributor + explicitly and finally terminates Your grants, and (b) on an ongoing basis, + if such Contributor fails to notify You of the non-compliance by some + reasonable means prior to 60 days after You have come back into compliance. + Moreover, Your grants from a particular Contributor are reinstated on an + ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of + non-compliance with this License from such Contributor, and You become + compliant prior to 30 days after Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, counter-claims, + and cross-claims) alleging that a Contributor Version directly or + indirectly infringes any patent, then the rights granted to You by any and + all Contributors for the Covered Software under Section 2.1 of this License + shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user + license agreements (excluding distributors and resellers) which have been + validly granted by You or Your distributors under this License prior to + termination shall survive termination. + +6. Disclaimer of Warranty + + Covered Software is provided under this License on an "as is" basis, without + warranty of any kind, either expressed, implied, or statutory, including, + without limitation, warranties that the Covered Software is free of defects, + merchantable, fit for a particular purpose or non-infringing. The entire + risk as to the quality and performance of the Covered Software is with You. + Should any Covered Software prove defective in any respect, You (not any + Contributor) assume the cost of any necessary servicing, repair, or + correction. This disclaimer of warranty constitutes an essential part of this + License. No use of any Covered Software is authorized under this License + except under this disclaimer. + +7. Limitation of Liability + + Under no circumstances and under no legal theory, whether tort (including + negligence), contract, or otherwise, shall any Contributor, or anyone who + distributes Covered Software as permitted above, be liable to You for any + direct, indirect, special, incidental, or consequential damages of any + character including, without limitation, damages for lost profits, loss of + goodwill, work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses, even if such party shall have been + informed of the possibility of such damages. This limitation of liability + shall not apply to liability for death or personal injury resulting from such + party’s negligence to the extent applicable law prohibits such limitation. + Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You. + +8. Litigation + + Any litigation relating to this License may be brought only in the courts of + a jurisdiction where the defendant maintains its principal place of business + and such litigation shall be governed by laws of that jurisdiction, without + reference to its conflict-of-law provisions. Nothing in this Section shall + prevent a party’s ability to bring cross-claims or counter-claims. + +9. Miscellaneous + + This License represents the complete agreement concerning the subject matter + hereof. If any provision of this License is held to be unenforceable, such + provision shall be reformed only to the extent necessary to make it + enforceable. Any law or regulation which provides that the language of a + contract shall be construed against the drafter shall not be used to construe + this License against a Contributor. + + +10. Versions of the License + +10.1. New Versions + + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + +10.2. Effect of New Versions + + You may distribute the Covered Software under the terms of the version of + the License under which You originally received the Covered Software, or + under the terms of any subsequent version published by the license + steward. + +10.3. Modified Versions + + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a modified + version of this License if you rename the license and remove any + references to the name of the license steward (except to note that such + modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice + + This Source Code Form is subject to the + terms of the Mozilla Public License, v. + 2.0. If a copy of the MPL was not + distributed with this file, You can + obtain one at + http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular file, then +You may include the notice in a location (such as a LICENSE file in a relevant +directory) where a recipient would be likely to look for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice + + This Source Code Form is "Incompatible + With Secondary Licenses", as defined by + the Mozilla Public License, v. 2.0. +--------------------------------------------------------- + +--------------------------------------------------------- + dotenv-org/dotenv-vscode 0.26.0 - MIT License https://github.com/dotenv-org/dotenv-vscode @@ -684,7 +1258,7 @@ more details. --------------------------------------------------------- -go-syntax 0.8.5 - MIT +go-syntax 0.8.6 - MIT https://github.com/worlpaker/go-syntax MIT License @@ -2277,7 +2851,7 @@ written authorization of the copyright holder. --------------------------------------------------------- -vscode-codicons 0.0.41 - MIT and Creative Commons Attribution 4.0 +vscode-codicons 0.0.46-0 - MIT and Creative Commons Attribution 4.0 https://github.com/microsoft/vscode-codicons Attribution 4.0 International diff --git a/build/.moduleignore b/build/.moduleignore index ed36151130cc1..faa4973e2dcac 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -188,3 +188,28 @@ zone.js/dist/** @xterm/xterm-addon-*/fixtures/** @xterm/xterm-addon-*/out/** @xterm/xterm-addon-*/out-test/** + +# @github/copilot - strip unneeded binaries and files +@github/copilot/sdk/index.js +@github/copilot/prebuilds/** +@github/copilot/clipboard/** +@github/copilot/ripgrep/** +@github/copilot/**/keytar.node + +# @github/copilot platform binaries - not needed +@github/copilot-darwin-arm64/** +@github/copilot-darwin-x64/** +@github/copilot-linux-arm64/** +@github/copilot-linux-x64/** +@github/copilot-win32-arm64/** +@github/copilot-win32-x64/** + +# @github/copilot-sdk - strip the nested @github/copilot CLI runtime +# The SDK only needs its own dist/ files; the CLI is resolved via cliPath at runtime +@github/copilot-sdk/node_modules/@github/copilot/** +@github/copilot-sdk/node_modules/@github/copilot-darwin-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-darwin-x64/** +@github/copilot-sdk/node_modules/@github/copilot-linux-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-linux-x64/** +@github/copilot-sdk/node_modules/@github/copilot-win32-arm64/** +@github/copilot-sdk/node_modules/@github/copilot-win32-x64/** diff --git a/build/.npmrc b/build/.npmrc index 551822f79cd63..f1c087f86b52c 100644 --- a/build/.npmrc +++ b/build/.npmrc @@ -4,3 +4,4 @@ build_from_source="true" legacy-peer-deps="true" force_process_config="true" timeout=180000 +min-release-age="1" diff --git a/build/azure-pipelines/alpine/product-build-alpine.yml b/build/azure-pipelines/alpine/product-build-alpine.yml index 5c5714e9d5b12..a9a1b0d1292ba 100644 --- a/build/azure-pipelines/alpine/product-build-alpine.yml +++ b/build/azure-pipelines/alpine/product-build-alpine.yml @@ -64,15 +64,6 @@ jobs: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: DownloadPipelineArtifact@2 - inputs: - artifact: Compilation - path: $(Build.ArtifactStagingDirectory) - displayName: Download compilation output - - - script: tar -xzf $(Build.ArtifactStagingDirectory)/compilation.tar.gz - displayName: Extract compilation output - - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry @@ -173,6 +164,11 @@ jobs: - template: ../common/install-builtin-extensions.yml@self + - script: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile + - script: | set -e TARGET=$([ "$VSCODE_ARCH" == "x64" ] && echo "linux-alpine" || echo "alpine-arm64") # TODO@joaomoreno diff --git a/build/azure-pipelines/common/extract-telemetry.sh b/build/azure-pipelines/common/extract-telemetry.sh deleted file mode 100755 index 9cebe22bfd189..0000000000000 --- a/build/azure-pipelines/common/extract-telemetry.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd $BUILD_STAGINGDIRECTORY -mkdir extraction -cd extraction -git clone --depth 1 https://github.com/microsoft/vscode-extension-telemetry.git -git clone --depth 1 https://github.com/microsoft/vscode-chrome-debug-core.git -git clone --depth 1 https://github.com/microsoft/vscode-node-debug2.git -git clone --depth 1 https://github.com/microsoft/vscode-node-debug.git -git clone --depth 1 https://github.com/microsoft/vscode-html-languageservice.git -git clone --depth 1 https://github.com/microsoft/vscode-json-languageservice.git -node $BUILD_SOURCESDIRECTORY/node_modules/.bin/vscode-telemetry-extractor --sourceDir $BUILD_SOURCESDIRECTORY --excludedDir $BUILD_SOURCESDIRECTORY/extensions --outputDir . --applyEndpoints -node $BUILD_SOURCESDIRECTORY/node_modules/.bin/vscode-telemetry-extractor --config $BUILD_SOURCESDIRECTORY/build/azure-pipelines/common/telemetry-config.json -o . -mkdir -p $BUILD_SOURCESDIRECTORY/.build/telemetry -mv declarations-resolved.json $BUILD_SOURCESDIRECTORY/.build/telemetry/telemetry-core.json -mv config-resolved.json $BUILD_SOURCESDIRECTORY/.build/telemetry/telemetry-extensions.json -cd .. -rm -rf extraction diff --git a/build/azure-pipelines/common/extract-telemetry.ts b/build/azure-pipelines/common/extract-telemetry.ts new file mode 100644 index 0000000000000..a5fafac71d5f8 --- /dev/null +++ b/build/azure-pipelines/common/extract-telemetry.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import cp from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const BUILD_STAGINGDIRECTORY = process.env.BUILD_STAGINGDIRECTORY ?? fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-telemetry-')); +const BUILD_SOURCESDIRECTORY = process.env.BUILD_SOURCESDIRECTORY ?? path.resolve(import.meta.dirname, '..', '..', '..'); + +const extractionDir = path.join(BUILD_STAGINGDIRECTORY, 'extraction'); +fs.mkdirSync(extractionDir, { recursive: true }); + +const repos = [ + 'https://github.com/microsoft/vscode-extension-telemetry.git', + 'https://github.com/microsoft/vscode-chrome-debug-core.git', + 'https://github.com/microsoft/vscode-node-debug2.git', + 'https://github.com/microsoft/vscode-node-debug.git', + 'https://github.com/microsoft/vscode-html-languageservice.git', + 'https://github.com/microsoft/vscode-json-languageservice.git', +]; + +for (const repo of repos) { + cp.execSync(`git clone --depth 1 ${repo}`, { cwd: extractionDir, stdio: 'inherit' }); +} + +const extractor = path.join(BUILD_SOURCESDIRECTORY, 'node_modules', '@vscode', 'telemetry-extractor', 'out', 'extractor.js'); +const telemetryConfig = path.join(BUILD_SOURCESDIRECTORY, 'build', 'azure-pipelines', 'common', 'telemetry-config.json'); + +interface ITelemetryConfigEntry { + eventPrefix: string; + sourceDirs: string[]; + excludedDirs: string[]; + applyEndpoints: boolean; + patchDebugEvents?: boolean; +} + +const pipelineExtensionsPathPrefix = '../../s/extensions/'; + +const telemetryConfigEntries = JSON.parse(fs.readFileSync(telemetryConfig, 'utf8')) as ITelemetryConfigEntry[]; +let hasLocalConfigOverrides = false; + +const resolvedTelemetryConfigEntries = telemetryConfigEntries.map(entry => { + const sourceDirs = entry.sourceDirs.map(sourceDir => { + if (!sourceDir.startsWith(pipelineExtensionsPathPrefix)) { + return sourceDir; + } + + const sourceDirInExtractionDir = path.resolve(extractionDir, sourceDir); + if (fs.existsSync(sourceDirInExtractionDir)) { + return sourceDir; + } + + const extensionRelativePath = sourceDir.slice(pipelineExtensionsPathPrefix.length); + const sourceDirInWorkspace = path.join(BUILD_SOURCESDIRECTORY, 'extensions', extensionRelativePath); + if (fs.existsSync(sourceDirInWorkspace)) { + hasLocalConfigOverrides = true; + return sourceDirInWorkspace; + } + + return sourceDir; + }); + + return { + ...entry, + sourceDirs, + }; +}); + +const telemetryConfigForExtraction = hasLocalConfigOverrides + ? path.join(extractionDir, 'telemetry-config.local.json') + : telemetryConfig; + +if (hasLocalConfigOverrides) { + fs.writeFileSync(telemetryConfigForExtraction, JSON.stringify(resolvedTelemetryConfigEntries, null, '\t')); +} + +try { + cp.execSync(`node "${extractor}" --sourceDir "${BUILD_SOURCESDIRECTORY}" --excludedDir "${path.join(BUILD_SOURCESDIRECTORY, 'extensions')}" --outputDir . --applyEndpoints`, { cwd: extractionDir, stdio: 'inherit' }); + cp.execSync(`node "${extractor}" --config "${telemetryConfigForExtraction}" -o .`, { cwd: extractionDir, stdio: 'inherit' }); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Telemetry extraction failed: ${message}`); + process.exit(1); +} + +const telemetryDir = path.join(BUILD_SOURCESDIRECTORY, '.build', 'telemetry'); +fs.mkdirSync(telemetryDir, { recursive: true }); +fs.renameSync(path.join(extractionDir, 'declarations-resolved.json'), path.join(telemetryDir, 'telemetry-core.json')); +fs.renameSync(path.join(extractionDir, 'config-resolved.json'), path.join(telemetryDir, 'telemetry-extensions.json')); + +fs.rmSync(extractionDir, { recursive: true, force: true }); diff --git a/build/azure-pipelines/common/publish.ts b/build/azure-pipelines/common/publish.ts index 572efa57bf998..fd621e4224021 100644 --- a/build/azure-pipelines/common/publish.ts +++ b/build/azure-pipelines/common/publish.ts @@ -970,15 +970,7 @@ async function main() { console.log(`\u2705 ${name}`); } - const stages = new Set(['Compile']); - - if ( - e('VSCODE_BUILD_STAGE_LINUX') === 'True' || - e('VSCODE_BUILD_STAGE_MACOS') === 'True' || - e('VSCODE_BUILD_STAGE_WINDOWS') === 'True' - ) { - stages.add('CompileCLI'); - } + const stages = new Set(['Quality']); if (e('VSCODE_BUILD_STAGE_WINDOWS') === 'True') { stages.add('Windows'); } if (e('VSCODE_BUILD_STAGE_LINUX') === 'True') { stages.add('Linux'); } diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 3cd8082308e28..708978a130cad 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -66,15 +66,8 @@ async function main(force: boolean): Promise { console.log(`Releasing build ${commit}...`); - let rolloutDurationMs = undefined; - - // If the build is insiders or exploration, start a rollout of 4 hours - if (quality === 'insider') { - rolloutDurationMs = 4 * 60 * 60 * 1000; // 4 hours - } - const scripts = client.database('builds').container(quality).scripts; - await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit, rolloutDurationMs])); + await retry(() => scripts.storedProcedure('releaseBuild').execute('', [commit])); } const [, , force] = process.argv; diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index 3606777f9a375..fdf6b2cd3dd17 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -29,18 +29,36 @@ jobs: name: ${{ parameters.poolName }} os: ${{ parameters.os }} timeoutInMinutes: 30 + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(SCREENSHOTS_DIR) + artifactName: screenshots-${{ parameters.name }}-$(System.JobAttempt) + displayName: Publish Screenshots + condition: and(succeededOrFailed(), eq(variables.HAS_SCREENSHOTS, 'true')) + continueOnError: true + sbomEnabled: false variables: TEST_DIR: $(Build.SourcesDirectory)/test/sanity LOG_FILE: $(TEST_DIR)/results.xml + SCREENSHOTS_DIR: $(TEST_DIR)/screenshots DOCKER_CACHE_DIR: $(Pipeline.Workspace)/docker-cache DOCKER_CACHE_FILE: $(DOCKER_CACHE_DIR)/${{ parameters.container }}.tar steps: - checkout: self fetchDepth: 1 fetchTags: false - sparseCheckoutDirectories: test/sanity .nvmrc + sparseCheckoutDirectories: build/azure-pipelines/config test/sanity .nvmrc displayName: Checkout test/sanity + - ${{ if eq(parameters.os, 'windows') }}: + - script: mkdir "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + + - ${{ else }}: + - bash: mkdir -p "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: - script: | @echo off @@ -101,19 +119,19 @@ jobs: # Windows - ${{ if eq(parameters.os, 'windows') }}: - - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # macOS - ${{ if eq(parameters.os, 'macOS') }}: - - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # Native Linux host - ${{ if and(eq(parameters.container, ''), eq(parameters.os, 'linux')) }}: - - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests @@ -141,6 +159,7 @@ jobs: --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ + --screenshots-dir "/root/screenshots" \ --verbose \ ${{ parameters.args }} workingDirectory: $(TEST_DIR) @@ -152,6 +171,25 @@ jobs: condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) displayName: Save Docker Image + - ${{ if eq(parameters.os, 'windows') }}: + - script: | + @echo off + dir /b "$(SCREENSHOTS_DIR)" 2>nul | findstr . >nul + if %errorlevel%==0 ( + echo ##vso[task.setvariable variable=HAS_SCREENSHOTS]true + ) + exit /b 0 + displayName: Check Screenshots + condition: succeededOrFailed() + + - ${{ else }}: + - bash: | + if [ -n "$(ls -A "$(SCREENSHOTS_DIR)" 2>/dev/null)" ]; then + echo "##vso[task.setvariable variable=HAS_SCREENSHOTS]true" + fi + displayName: Check Screenshots + condition: succeededOrFailed() + - task: PublishTestResults@2 inputs: testResultsFormat: JUnit diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml b/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml deleted file mode 100644 index 94eee5e476c2a..0000000000000 --- a/build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml +++ /dev/null @@ -1,86 +0,0 @@ -parameters: - - name: VSCODE_BUILD_MACOS - type: boolean - - name: VSCODE_BUILD_MACOS_ARM64 - type: boolean - -jobs: - - job: macOSCLISign - timeoutInMinutes: 90 - templateContext: - outputParentDirectory: $(Build.ArtifactStagingDirectory)/out - outputs: - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_x64_cli/vscode_cli_darwin_x64_cli.zip - artifactName: vscode_cli_darwin_x64_cli - displayName: Publish signed artifact with ID vscode_cli_darwin_x64_cli - sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign/unsigned_vscode_cli_darwin_x64_cli - sbomPackageName: "VS Code macOS x64 CLI" - sbomPackageVersion: $(Build.SourceVersion) - - ${{ if eq(parameters.VSCODE_BUILD_MACOS_ARM64, true) }}: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_arm64_cli/vscode_cli_darwin_arm64_cli.zip - artifactName: vscode_cli_darwin_arm64_cli - displayName: Publish signed artifact with ID vscode_cli_darwin_arm64_cli - sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign/unsigned_vscode_cli_darwin_arm64_cli - sbomPackageName: "VS Code macOS arm64 CLI" - sbomPackageVersion: $(Build.SourceVersion) - steps: - - template: ../common/checkout.yml@self - - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - - script: node build/setup-npm-registry.ts $NPM_REGISTRY build - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM Registry - - - script: | - set -e - # Set the private NPM registry to the global npmrc file - # so that authentication works for subfolders like build/, remote/, extensions/ etc - # which does not have their own .npmrc file - npm config set registry "$NPM_REGISTRY" - echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM Authentication - - - script: | - set -e - - for i in {1..5}; do # try 5 times - npm ci && break - if [ $i -eq 5 ]; then - echo "Npm install failed too many times" >&2 - exit 1 - fi - echo "Npm install failed $i, trying again..." - done - workingDirectory: build - env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" - displayName: Install build dependencies - - - template: ./steps/product-build-darwin-cli-sign.yml@self - parameters: - VSCODE_CLI_ARTIFACTS: - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - unsigned_vscode_cli_darwin_x64_cli - - ${{ if eq(parameters.VSCODE_BUILD_MACOS_ARM64, true) }}: - - unsigned_vscode_cli_darwin_arm64_cli diff --git a/build/azure-pipelines/darwin/product-build-darwin-cli.yml b/build/azure-pipelines/darwin/product-build-darwin-cli.yml index dc5a5d79c1457..1b6ea51bd146f 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-cli.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-cli.yml @@ -9,8 +9,8 @@ parameters: jobs: - job: macOSCLI_${{ parameters.VSCODE_ARCH }} - displayName: macOS (${{ upper(parameters.VSCODE_ARCH) }}) - timeoutInMinutes: 60 + displayName: macOS CLI (${{ upper(parameters.VSCODE_ARCH) }}) + timeoutInMinutes: 90 pool: name: AcesShared os: macOS @@ -24,11 +24,12 @@ jobs: outputs: - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip - artifactName: unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli - displayName: Publish unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli artifact - sbomEnabled: false - isProduction: false + targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_$(VSCODE_ARCH)_cli/vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip + artifactName: vscode_cli_darwin_$(VSCODE_ARCH)_cli + displayName: Publish vscode_cli_darwin_$(VSCODE_ARCH)_cli artifact + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + sbomPackageName: "VS Code macOS $(VSCODE_ARCH) CLI" + sbomPackageVersion: $(Build.SourceVersion) steps: - template: ../common/checkout.yml@self @@ -83,3 +84,55 @@ jobs: VSCODE_CLI_ENV: OPENSSL_LIB_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/lib OPENSSL_INCLUDE_DIR: $(Build.ArtifactStagingDirectory)/openssl/arm64-osx/include + + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - template: ../common/publish-artifact.yml@self + parameters: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip + artifactName: unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + displayName: Publish unsigned CLI + sbomEnabled: false + + - script: | + set -e + mkdir -p $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + cp $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip + displayName: Prepare CLI for signing + + - task: ExtractFiles@1 + displayName: Extract unsigned CLI (for SBOM) + inputs: + archiveFilePatterns: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip + destinationFolder: $(Build.ArtifactStagingDirectory)/sign/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + + - task: UseDotNet@2 + inputs: + version: 6.x + + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' + + - script: node build/azure-pipelines/common/sign.ts $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli "*.zip" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Codesign + + - script: node build/azure-pipelines/common/sign.ts $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli "*.zip" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Notarize + + - script: | + set -e + mkdir -p $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_$(VSCODE_ARCH)_cli + mv $(Build.ArtifactStagingDirectory)/pkg/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli/unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip $(Build.ArtifactStagingDirectory)/out/vscode_cli_darwin_$(VSCODE_ARCH)_cli/vscode_cli_darwin_$(VSCODE_ARCH)_cli.zip + displayName: Rename signed artifact diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-cli-sign.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-cli-sign.yml deleted file mode 100644 index 1cd0fe2a8245f..0000000000000 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-cli-sign.yml +++ /dev/null @@ -1,53 +0,0 @@ -parameters: - - name: VSCODE_CLI_ARTIFACTS - type: object - default: [] - -steps: - - task: UseDotNet@2 - inputs: - version: 6.x - - - task: EsrpCodeSigning@5 - inputs: - UseMSIAuthentication: true - ConnectedServiceName: vscode-esrp - AppRegistrationClientId: $(ESRP_CLIENT_ID) - AppRegistrationTenantId: $(ESRP_TENANT_ID) - AuthAKVName: vscode-esrp - AuthSignCertName: esrp-sign - FolderPath: . - Pattern: noop - displayName: 'Install ESRP Tooling' - - - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - - task: DownloadPipelineArtifact@2 - displayName: Download ${{ target }} - inputs: - artifact: ${{ target }} - path: $(Build.ArtifactStagingDirectory)/pkg/${{ target }} - - - task: ExtractFiles@1 - displayName: Extract artifact - inputs: - archiveFilePatterns: $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/*.zip - destinationFolder: $(Build.ArtifactStagingDirectory)/sign/${{ target }} - - - script: node build/azure-pipelines/common/sign.ts $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll sign-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: ✍️ Codesign - - - script: node build/azure-pipelines/common/sign.ts $(Agent.RootDirectory)/_tasks/EsrpCodeSigning_*/*/net6.0/esrpcli.dll notarize-darwin $(Build.ArtifactStagingDirectory)/pkg "*.zip" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: ✍️ Notarize - - - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - - script: | - set -e - ASSET_ID=$(echo "${{ target }}" | sed "s/unsigned_//") - mkdir -p $(Build.ArtifactStagingDirectory)/out/$ASSET_ID - mv $(Build.ArtifactStagingDirectory)/pkg/${{ target }}/${{ target }}.zip $(Build.ArtifactStagingDirectory)/out/$ASSET_ID/$ASSET_ID.zip - echo "##vso[task.setvariable variable=ASSET_ID]$ASSET_ID" - displayName: Set asset id variable diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 64b91f714016f..cd5f6c287c01c 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -30,15 +30,6 @@ steps: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password,macos-developer-certificate,macos-developer-certificate-key" - - task: DownloadPipelineArtifact@2 - inputs: - artifact: Compilation - path: $(Build.ArtifactStagingDirectory) - displayName: Download compilation output - - - script: tar -xzf $(Build.ArtifactStagingDirectory)/compilation.tar.gz - displayName: Extract compilation output - - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry @@ -112,11 +103,33 @@ steps: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Create node_modules archive + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact (background) + - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality - template: ../../common/install-builtin-extensions.yml@self + - script: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile + + - script: node build/azure-pipelines/common/extract-telemetry.ts + displayName: Generate lists of telemetry events + + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - script: | + set -e + npm run compile --prefix test/smoke + npm run compile --prefix test/integration/browser + displayName: Compile test suites (non-OSS) + condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false')) + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - script: npm run copy-policy-dto --prefix build && node build/lib/policies/policyGenerator.ts build/lib/policies/policyData.jsonc darwin displayName: Generate policy definitions @@ -147,6 +160,11 @@ steps: displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact + - task: DownloadPipelineArtifact@2 inputs: artifact: unsigned_vscode_cli_darwin_$(VSCODE_ARCH)_cli diff --git a/build/azure-pipelines/product-npm-package-validate.yml b/build/azure-pipelines/dependencies-check.yml similarity index 80% rename from build/azure-pipelines/product-npm-package-validate.yml rename to build/azure-pipelines/dependencies-check.yml index 37483396b23e8..2c0d32b751aab 100644 --- a/build/azure-pipelines/product-npm-package-validate.yml +++ b/build/azure-pipelines/dependencies-check.yml @@ -1,8 +1,16 @@ trigger: none -pr: - branches: - include: ["main"] +pr: none + +parameters: + - name: GITHUB_APP_ID + type: string + - name: GITHUB_APP_INSTALLATION_ID + type: string + - name: GITHUB_APP_PRIVATE_KEY + type: string + - name: GITHUB_CHECK_RUN_ID + type: string variables: - name: NPM_REGISTRY @@ -12,11 +20,11 @@ variables: jobs: - job: ValidateNpmPackages - displayName: Valiate NPM packages against Terrapin + displayName: Validate package-lock.json, Cargo.lock changes via Azure DevOps pipeline pool: name: 1es-ubuntu-22.04-x64 os: linux - timeoutInMinutes: 40000 + timeoutInMinutes: 1300 variables: VSCODE_ARCH: x64 steps: @@ -75,10 +83,10 @@ jobs: - script: | set -e - for attempt in {1..12}; do + for attempt in {1..120}; do if [ $attempt -gt 1 ]; then - echo "Attempt $attempt: Waiting for 30 minutes before retrying..." - sleep 1800 + echo "Attempt $attempt: Waiting for 10 minutes before retrying..." + sleep 600 fi echo "Attempt $attempt: Running npm ci" @@ -94,7 +102,7 @@ jobs: fi done - echo "npm i failed after 12 attempts" + echo "giving up after 120 attempts (20 hours)" exit 1 env: npm_command: 'install --ignore-scripts' @@ -102,7 +110,7 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Install dependencies with retries - timeoutInMinutes: 400 + timeoutInMinutes: 1300 condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) - script: | @@ -114,3 +122,13 @@ jobs: - script: .github/workflows/check-clean-git-state.sh displayName: Check clean git state condition: and(succeeded(), eq(variables['SHOULD_VALIDATE'], 'true')) + + - script: node build/azure-pipelines/update-dependencies-check.ts + displayName: Update GitHub check run + condition: always() + env: + GITHUB_APP_ID: ${{ parameters.GITHUB_APP_ID }} + GITHUB_APP_INSTALLATION_ID: ${{ parameters.GITHUB_APP_INSTALLATION_ID }} + GITHUB_APP_PRIVATE_KEY: ${{ parameters.GITHUB_APP_PRIVATE_KEY }} + CHECK_RUN_ID: ${{ parameters.GITHUB_CHECK_RUN_ID }} + AGENT_JOBSTATUS: $(Agent.JobStatus) diff --git a/build/azure-pipelines/github-check-run.js b/build/azure-pipelines/github-check-run.js new file mode 100644 index 0000000000000..e6c2a8a892d65 --- /dev/null +++ b/build/azure-pipelines/github-check-run.js @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +const crypto = require('crypto'); +const https = require('https'); + +/** + * @param {string} appId + * @param {string} privateKey + * @returns {string} + */ +function createJwt(appId, privateKey) { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId })).toString('base64url'); + const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url'); + return `${header}.${payload}.${signature}`; +} + +/** + * @param {import('https').RequestOptions} options + * @param {object} [body] + * @returns {Promise} + */ +function request(options, body) { + return new Promise((resolve, reject) => { + const req = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(JSON.parse(data)); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + req.on('error', reject); + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +/** + * @param {string} jwt + * @param {string} installationId + * @returns {Promise} + */ +async function getInstallationToken(jwt, installationId) { + /** @type {{ token: string }} */ + const result = await request({ + hostname: 'api.github.com', + path: `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'VSCode-ADO-Pipeline', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + return result.token; +} + +/** + * @param {string} token + * @param {string} checkRunId + * @param {string} conclusion + * @param {string} detailsUrl + */ +function updateCheckRun(token, checkRunId, conclusion, detailsUrl) { + return request({ + hostname: 'api.github.com', + path: `/repos/microsoft/vscode/check-runs/${encodeURIComponent(checkRunId)}`, + method: 'PATCH', + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'VSCode-ADO-Pipeline', + 'X-GitHub-Api-Version': '2022-11-28' + } + }, { + status: 'completed', + conclusion, + completed_at: new Date().toISOString(), + details_url: detailsUrl + }); +} + +async function main() { + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; + const installationId = process.env.GITHUB_APP_INSTALLATION_ID; + const checkRunId = process.env.CHECK_RUN_ID; + const jobStatus = process.env.AGENT_JOBSTATUS; + const detailsUrl = `${process.env.SYSTEM_COLLECTIONURI}${process.env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${process.env.BUILD_BUILDID}`; + + if (!appId || !privateKey || !installationId || !checkRunId) { + throw new Error('Missing required environment variables'); + } + + const jwt = createJwt(appId, privateKey); + const token = await getInstallationToken(jwt, installationId); + + /** @type {string} */ + let conclusion; + switch (jobStatus) { + case 'Succeeded': + case 'SucceededWithIssues': + conclusion = 'success'; + break; + case 'Canceled': + conclusion = 'cancelled'; + break; + default: + conclusion = 'failure'; + break; + } + + await updateCheckRun(token, checkRunId, conclusion, detailsUrl); + console.log(`Updated check run ${checkRunId} with conclusion: ${conclusion}`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/build/azure-pipelines/linux/product-build-linux-ci.yml b/build/azure-pipelines/linux/product-build-linux-ci.yml index 6c6b102891a7e..619aff676407e 100644 --- a/build/azure-pipelines/linux/product-build-linux-ci.yml +++ b/build/azure-pipelines/linux/product-build-linux-ci.yml @@ -5,6 +5,9 @@ parameters: type: string - name: VSCODE_TEST_SUITE type: string + - name: VSCODE_RUN_CHECKS + type: boolean + default: false jobs: - job: Linux${{ parameters.VSCODE_TEST_SUITE }} @@ -43,6 +46,7 @@ jobs: VSCODE_ARCH: x64 VSCODE_CIBUILD: ${{ parameters.VSCODE_CIBUILD }} VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + VSCODE_RUN_CHECKS: ${{ parameters.VSCODE_RUN_CHECKS }} ${{ if eq(parameters.VSCODE_TEST_SUITE, 'Electron') }}: VSCODE_RUN_ELECTRON_TESTS: true ${{ if eq(parameters.VSCODE_TEST_SUITE, 'Browser') }}: diff --git a/build/azure-pipelines/linux/product-build-linux-cli.yml b/build/azure-pipelines/linux/product-build-linux-cli.yml index ef160c2cc3849..a9107129b73b5 100644 --- a/build/azure-pipelines/linux/product-build-linux-cli.yml +++ b/build/azure-pipelines/linux/product-build-linux-cli.yml @@ -9,7 +9,7 @@ parameters: jobs: - job: LinuxCLI_${{ parameters.VSCODE_ARCH }} - displayName: Linux (${{ upper(parameters.VSCODE_ARCH) }}) + displayName: Linux CLI (${{ upper(parameters.VSCODE_ARCH) }}) timeoutInMinutes: 60 pool: name: 1es-ubuntu-22.04-x64 diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index 290a3fe1b29ed..ad0e149816014 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -41,7 +41,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/build/azure-pipelines/linux/product-build-linux.yml b/build/azure-pipelines/linux/product-build-linux.yml index 31eb7c3d46668..00ffd0aaab07e 100644 --- a/build/azure-pipelines/linux/product-build-linux.yml +++ b/build/azure-pipelines/linux/product-build-linux.yml @@ -19,6 +19,9 @@ parameters: - name: VSCODE_RUN_REMOTE_TESTS type: boolean default: false + - name: VSCODE_RUN_CHECKS + type: boolean + default: false jobs: - job: Linux_${{ parameters.VSCODE_ARCH }} @@ -26,6 +29,7 @@ jobs: timeoutInMinutes: 90 variables: DISPLAY: ":10" + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ NPM_ARCH: ${{ parameters.NPM_ARCH }} VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} templateContext: @@ -110,3 +114,4 @@ jobs: VSCODE_RUN_ELECTRON_TESTS: ${{ parameters.VSCODE_RUN_ELECTRON_TESTS }} VSCODE_RUN_BROWSER_TESTS: ${{ parameters.VSCODE_RUN_BROWSER_TESTS }} VSCODE_RUN_REMOTE_TESTS: ${{ parameters.VSCODE_RUN_REMOTE_TESTS }} + VSCODE_RUN_CHECKS: ${{ parameters.VSCODE_RUN_CHECKS }} diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 89199ebbbb14c..4b5a5d08cd037 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -17,6 +17,9 @@ parameters: - name: VSCODE_RUN_REMOTE_TESTS type: boolean default: false + - name: VSCODE_RUN_CHECKS + type: boolean + default: false steps: - template: ../../common/checkout.yml@self @@ -35,15 +38,6 @@ steps: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: DownloadPipelineArtifact@2 - inputs: - artifact: Compilation - path: $(Build.ArtifactStagingDirectory) - displayName: Download compilation output - - - script: tar -xzf $(Build.ArtifactStagingDirectory)/compilation.tar.gz - displayName: Extract compilation output - - script: | set -e # Start X server @@ -54,7 +48,9 @@ steps: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults @@ -165,11 +161,34 @@ steps: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Create node_modules archive + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts $(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact (background) + - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality - template: ../../common/install-builtin-extensions.yml@self + - script: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile + + - ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}: + - script: node build/azure-pipelines/common/extract-telemetry.ts + displayName: Generate lists of telemetry events + + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - script: | + set -e + npm run compile --prefix test/smoke + npm run compile --prefix test/integration/browser + displayName: Compile test suites (non-OSS) + condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false')) + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - script: npm run copy-policy-dto --prefix build && node build/lib/policies/policyGenerator.ts build/lib/policies/policyData.jsonc linux displayName: Generate policy definitions @@ -187,6 +206,11 @@ steps: displayName: Build client - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts $(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact + - task: DownloadPipelineArtifact@2 inputs: artifact: $(ARTIFACT_PREFIX)vscode_cli_linux_$(VSCODE_ARCH)_cli @@ -323,7 +347,7 @@ steps: - script: | set -e npm run gulp "vscode-linux-$(VSCODE_ARCH)-prepare-snap" - sudo -E docker run -e VSCODE_ARCH -e VSCODE_QUALITY -v $(pwd):/work -w /work vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64 /bin/bash -c "./build/azure-pipelines/linux/build-snap.sh" + sudo -E docker run -e VSCODE_ARCH -e VSCODE_QUALITY -v $(pwd):/work -w /work vscodehub.azurecr.io/vscode-linux-build-agent:snapcraft-x64@sha256:ab4a88c4d85e0d7a85acabba59543f7143f575bab2c0b2b07f5b77d4a7e491ff /bin/bash -c "./build/azure-pipelines/linux/build-snap.sh" SNAP_ROOT="$(pwd)/.build/linux/snap/$(VSCODE_ARCH)" SNAP_EXTRACTED_PATH=$(find $SNAP_ROOT -maxdepth 1 -type d -name 'code-*') diff --git a/build/azure-pipelines/product-build-macos.yml b/build/azure-pipelines/product-build-macos.yml deleted file mode 100644 index cc563953b0071..0000000000000 --- a/build/azure-pipelines/product-build-macos.yml +++ /dev/null @@ -1,106 +0,0 @@ -pr: none - -trigger: none - -parameters: - - name: VSCODE_QUALITY - displayName: Quality - type: string - default: insider - - name: NPM_REGISTRY - displayName: "Custom NPM Registry" - type: string - default: 'https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/' - - name: CARGO_REGISTRY - displayName: "Custom Cargo Registry" - type: string - default: 'sparse+https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/Cargo/index/' - -variables: - - name: NPM_REGISTRY - ${{ if in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI') }}: # disable terrapin when in VSCODE_CIBUILD - value: none - ${{ else }}: - value: ${{ parameters.NPM_REGISTRY }} - - name: CARGO_REGISTRY - value: ${{ parameters.CARGO_REGISTRY }} - - name: VSCODE_QUALITY - value: ${{ parameters.VSCODE_QUALITY }} - - name: VSCODE_CIBUILD - value: ${{ in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI') }} - - name: VSCODE_STEP_ON_IT - value: false - - name: skipComponentGovernanceDetection - value: true - - name: ComponentDetection.Timeout - value: 600 - - name: Codeql.SkipTaskAutoInjection - value: true - - name: ARTIFACT_PREFIX - value: '' - -name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" - -resources: - repositories: - - repository: 1esPipelines - type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates - ref: refs/tags/release - -extends: - template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines - parameters: - sdl: - tsa: - enabled: true - configFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/tsaoptions.json - codeql: - runSourceLanguagesInSourceAnalysis: true - compiled: - enabled: false - justificationForDisabling: "CodeQL breaks ESRP CodeSign on macOS (ICM #520035761, githubcustomers/microsoft-codeql-support#198)" - credscan: - suppressionsFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/CredScanSuppressions.json - eslint: - enabled: true - enableExclusions: true - exclusionsFilePath: $(Build.SourcesDirectory)/.eslint-ignore - sourceAnalysisPool: 1es-windows-2022-x64 - createAdoIssuesForJustificationsForDisablement: false - containers: - ubuntu-2004-arm64: - image: onebranch.azurecr.io/linux/ubuntu-2004-arm64:latest - stages: - - stage: Compile - pool: - name: AcesShared - os: macOS - demands: - - ImageOverride -equals ACES_VM_SharedPool_Sequoia - jobs: - - template: build/azure-pipelines/product-compile.yml@self - - - stage: macOS - dependsOn: - - Compile - pool: - name: AcesShared - os: macOS - demands: - - ImageOverride -equals ACES_VM_SharedPool_Sequoia - variables: - BUILDSECMON_OPT_IN: true - jobs: - - template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self - parameters: - VSCODE_CIBUILD: true - VSCODE_TEST_SUITE: Electron - - template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self - parameters: - VSCODE_CIBUILD: true - VSCODE_TEST_SUITE: Browser - - template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self - parameters: - VSCODE_CIBUILD: true - VSCODE_TEST_SUITE: Remote diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 77c3dd0665f9e..fa1cc1a7699fa 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -26,6 +26,13 @@ parameters: - exploration - insider - stable + - name: VSCODE_BUILD_TYPE + displayName: Build Type + type: string + default: Product + values: + - Product + - CI - name: NPM_REGISTRY displayName: "Custom NPM Registry" type: string @@ -90,10 +97,6 @@ parameters: displayName: "Release build if successful" type: boolean default: false - - name: VSCODE_COMPILE_ONLY - displayName: "Run Compile stage exclusively" - type: boolean - default: false - name: VSCODE_STEP_ON_IT displayName: "Skip tests" type: boolean @@ -119,9 +122,9 @@ variables: - name: VSCODE_BUILD_STAGE_WEB value: ${{ eq(parameters.VSCODE_BUILD_WEB, true) }} - name: VSCODE_CIBUILD - value: ${{ in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI') }} + value: ${{ or(and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI'), not(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'))), eq(parameters.VSCODE_BUILD_TYPE, 'CI')) }} - name: VSCODE_PUBLISH - value: ${{ and(eq(parameters.VSCODE_PUBLISH, true), eq(variables.VSCODE_CIBUILD, false), eq(parameters.VSCODE_COMPILE_ONLY, false)) }} + value: ${{ and(eq(parameters.VSCODE_PUBLISH, true), eq(variables.VSCODE_CIBUILD, false)) }} - name: VSCODE_SCHEDULEDBUILD value: ${{ eq(variables['Build.Reason'], 'Schedule') }} - name: VSCODE_STEP_ON_IT @@ -190,27 +193,21 @@ extends: ubuntu-2004-arm64: image: onebranch.azurecr.io/linux/ubuntu-2004-arm64:latest stages: - - stage: Compile + + - stage: Quality + dependsOn: [] pool: - name: AcesShared - os: macOS - demands: - - ImageOverride -equals ACES_VM_SharedPool_Sequoia + name: 1es-ubuntu-22.04-x64 + os: linux jobs: - - template: build/azure-pipelines/product-compile.yml@self + - template: build/azure-pipelines/product-quality-checks.yml@self - - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: - - stage: ValidationChecks + - ${{ if eq(variables['VSCODE_BUILD_STAGE_WINDOWS'], true) }}: + - stage: Windows dependsOn: [] pool: - name: 1es-ubuntu-22.04-x64 - os: linux - jobs: - - template: build/azure-pipelines/product-validation-checks.yml@self - - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - stage: CompileCLI - dependsOn: [] + name: 1es-windows-2022-x64 + os: windows jobs: - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - template: build/azure-pipelines/win32/product-build-win32-cli.yml@self @@ -225,88 +222,6 @@ extends: VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: - - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: - - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self - parameters: - VSCODE_ARCH: arm64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: - - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self - parameters: - VSCODE_ARCH: armhf - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: - - template: build/azure-pipelines/darwin/product-build-darwin-cli.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: - - template: build/azure-pipelines/darwin/product-build-darwin-cli.yml@self - parameters: - VSCODE_ARCH: arm64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], true), eq(parameters.VSCODE_COMPILE_ONLY, false)) }}: - - stage: node_modules - dependsOn: [] - jobs: - - template: build/azure-pipelines/win32/product-build-win32-node-modules.yml@self - parameters: - VSCODE_ARCH: arm64 - - template: build/azure-pipelines/linux/product-build-linux-node-modules.yml@self - parameters: - NPM_ARCH: arm64 - VSCODE_ARCH: arm64 - - template: build/azure-pipelines/linux/product-build-linux-node-modules.yml@self - parameters: - NPM_ARCH: arm - VSCODE_ARCH: armhf - - template: build/azure-pipelines/alpine/product-build-alpine-node-modules.yml@self - parameters: - VSCODE_ARCH: x64 - - template: build/azure-pipelines/alpine/product-build-alpine-node-modules.yml@self - parameters: - VSCODE_ARCH: arm64 - - template: build/azure-pipelines/darwin/product-build-darwin-node-modules.yml@self - parameters: - VSCODE_ARCH: x64 - - template: build/azure-pipelines/web/product-build-web-node-modules.yml@self - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false)) }}: - - stage: APIScan - dependsOn: [] - pool: - name: 1es-windows-2022-x64 - os: windows - jobs: - - job: WindowsAPIScan - steps: - - template: build/azure-pipelines/win32/sdl-scan-win32.yml@self - parameters: - VSCODE_ARCH: x64 - VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WINDOWS'], true)) }}: - - stage: Windows - dependsOn: - - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - CompileCLI - pool: - name: 1es-windows-2022-x64 - os: windows - jobs: - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - template: build/azure-pipelines/win32/product-build-win32-ci.yml@self parameters: @@ -341,22 +256,32 @@ extends: VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), or(eq(parameters.VSCODE_BUILD_WIN32, true), eq(parameters.VSCODE_BUILD_WIN32_ARM64, true))) }}: - - template: build/azure-pipelines/win32/product-build-win32-cli-sign.yml@self - parameters: - VSCODE_BUILD_WIN32: ${{ parameters.VSCODE_BUILD_WIN32 }} - VSCODE_BUILD_WIN32_ARM64: ${{ parameters.VSCODE_BUILD_WIN32_ARM64 }} - - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_LINUX'], true)) }}: + - ${{ if eq(variables['VSCODE_BUILD_STAGE_LINUX'], true) }}: - stage: Linux - dependsOn: - - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - CompileCLI + dependsOn: [] pool: name: 1es-ubuntu-22.04-x64 os: linux jobs: + - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: + - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARM64, true)) }}: + - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true)) }}: + - template: build/azure-pipelines/linux/product-build-linux-cli.yml@self + parameters: + VSCODE_ARCH: armhf + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - template: build/azure-pipelines/linux/product-build-linux-ci.yml@self parameters: @@ -402,10 +327,9 @@ extends: VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} VSCODE_CIBUILD: ${{ variables.VSCODE_CIBUILD }} - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_ALPINE'], true)) }}: + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_STAGE_ALPINE'], true)) }}: - stage: Alpine - dependsOn: - - Compile + dependsOn: [] jobs: - ${{ if eq(parameters.VSCODE_BUILD_ALPINE, true) }}: - template: build/azure-pipelines/alpine/product-build-alpine.yml@self @@ -424,12 +348,9 @@ extends: VSCODE_ARCH: arm64 VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} - - ${{ if and(eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_MACOS'], true)) }}: + - ${{ if eq(variables['VSCODE_BUILD_STAGE_MACOS'], true) }}: - stage: macOS - dependsOn: - - Compile - - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - - CompileCLI + dependsOn: [] pool: name: AcesShared os: macOS @@ -438,6 +359,19 @@ extends: variables: BUILDSECMON_OPT_IN: true jobs: + - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: + - template: build/azure-pipelines/darwin/product-build-darwin-cli.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CHECK_ONLY: ${{ variables.VSCODE_CIBUILD }} + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true)) }}: + - template: build/azure-pipelines/darwin/product-build-darwin-cli.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: - template: build/azure-pipelines/darwin/product-build-darwin-ci.yml@self parameters: @@ -470,20 +404,13 @@ extends: - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_MACOS_UNIVERSAL'], true)) }}: - template: build/azure-pipelines/darwin/product-build-darwin-universal.yml@self - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), or(eq(parameters.VSCODE_BUILD_MACOS, true), eq(parameters.VSCODE_BUILD_MACOS_ARM64, true))) }}: - - template: build/azure-pipelines/darwin/product-build-darwin-cli-sign.yml@self - parameters: - VSCODE_BUILD_MACOS: ${{ parameters.VSCODE_BUILD_MACOS }} - VSCODE_BUILD_MACOS_ARM64: ${{ parameters.VSCODE_BUILD_MACOS_ARM64 }} - - - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(parameters.VSCODE_COMPILE_ONLY, false), eq(variables['VSCODE_BUILD_STAGE_WEB'], true)) }}: + - ${{ if and(eq(variables['VSCODE_CIBUILD'], false), eq(variables['VSCODE_BUILD_STAGE_WEB'], true)) }}: - stage: Web - dependsOn: - - Compile + dependsOn: [] jobs: - template: build/azure-pipelines/web/product-build-web.yml@self - - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: + - ${{ if eq(variables['VSCODE_PUBLISH'], true) }}: - stage: Publish dependsOn: [] jobs: @@ -783,7 +710,7 @@ extends: baseImage: ubuntu:24.04 arch: arm64 - - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false)) }}: + - ${{ if and(parameters.VSCODE_RELEASE, eq(variables['VSCODE_PRIVATE_BUILD'], false), or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/'))) }}: - stage: ApproveRelease dependsOn: [] # run in parallel to compile stage pool: @@ -811,3 +738,87 @@ extends: - template: build/azure-pipelines/product-release.yml@self parameters: VSCODE_RELEASE: ${{ parameters.VSCODE_RELEASE }} + + - ${{ if and(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')) }}: + - stage: TriggerStableBuild + displayName: Trigger Stable Build + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - job: TriggerStableBuild + displayName: Trigger Stable Build + steps: + - checkout: none + - script: | + set -e + node -e ' + async function main() { + const body = JSON.stringify({ + definition: { id: Number(process.env.DEFINITION_ID) }, + sourceBranch: process.env.SOURCE_BRANCH, + sourceVersion: process.env.SOURCE_VERSION, + templateParameters: { VSCODE_QUALITY: "stable", VSCODE_RELEASE: "false" } + }); + console.log(`Triggering stable build on ${process.env.SOURCE_BRANCH} @ ${process.env.SOURCE_VERSION}...`); + const response = await fetch(process.env.BUILDS_API_URL, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.SYSTEM_ACCESSTOKEN}`, "Content-Type": "application/json" }, + body + }); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}: ${await response.text()}`); + } + const build = await response.json(); + console.log(`Build queued successfully — ID: ${build.id}, URL: ${build._links.web.href}`); + } + main().catch(err => { console.error(err); process.exit(1); }); + ' + displayName: Queue stable build + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + DEFINITION_ID: $(System.DefinitionId) + SOURCE_BRANCH: $(Build.SourceBranch) + SOURCE_VERSION: $(Build.SourceVersion) + BUILDS_API_URL: $(System.TeamFoundationCollectionUri)$(System.TeamProject)/_apis/build/builds?api-version=7.0 + + - ${{ if eq(variables['VSCODE_CIBUILD'], true) }}: + - stage: node_modules + dependsOn: [] + jobs: + - template: build/azure-pipelines/win32/product-build-win32-node-modules.yml@self + parameters: + VSCODE_ARCH: arm64 + - template: build/azure-pipelines/linux/product-build-linux-node-modules.yml@self + parameters: + NPM_ARCH: arm64 + VSCODE_ARCH: arm64 + - template: build/azure-pipelines/linux/product-build-linux-node-modules.yml@self + parameters: + NPM_ARCH: arm + VSCODE_ARCH: armhf + - template: build/azure-pipelines/alpine/product-build-alpine-node-modules.yml@self + parameters: + VSCODE_ARCH: x64 + - template: build/azure-pipelines/alpine/product-build-alpine-node-modules.yml@self + parameters: + VSCODE_ARCH: arm64 + - template: build/azure-pipelines/darwin/product-build-darwin-node-modules.yml@self + parameters: + VSCODE_ARCH: x64 + - template: build/azure-pipelines/web/product-build-web-node-modules.yml@self + + - ${{ if eq(variables['VSCODE_CIBUILD'], false) }}: + - stage: APIScan + dependsOn: [] + pool: + name: 1es-windows-2022-x64 + os: windows + jobs: + - job: WindowsAPIScan + steps: + - template: build/azure-pipelines/win32/sdl-scan-win32.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} diff --git a/build/azure-pipelines/product-compile.yml b/build/azure-pipelines/product-quality-checks.yml similarity index 64% rename from build/azure-pipelines/product-compile.yml rename to build/azure-pipelines/product-quality-checks.yml index bc13d980df2dd..983a0a4b25aea 100644 --- a/build/azure-pipelines/product-compile.yml +++ b/build/azure-pipelines/product-quality-checks.yml @@ -1,14 +1,12 @@ jobs: - - job: Compile - timeoutInMinutes: 60 - templateContext: - outputs: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/compilation.tar.gz - artifactName: Compilation - displayName: Publish compilation artifact - isProduction: false - sbomEnabled: false + - job: Quality + displayName: Quality Checks + timeoutInMinutes: 20 + variables: + - name: skipComponentGovernanceDetection + value: true + - name: Codeql.SkipTaskAutoInjection + value: true steps: - template: ./common/checkout.yml@self @@ -30,7 +28,7 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry - - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts compile $(node -p process.arch) > .build/packagelockhash + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts quality $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key - task: Cache@2 @@ -46,9 +44,6 @@ jobs: - script: | set -e - # Set the private NPM registry to the global npmrc file - # so that authentication works for subfolders like build/, remote/, extensions/ etc - # which does not have their own .npmrc file npm config set registry "$NPM_REGISTRY" echo "##vso[task.setvariable variable=NPMRC_PATH]$(npm config get userconfig)" condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true'), ne(variables['NPM_REGISTRY'], 'none')) @@ -71,7 +66,38 @@ jobs: fi echo "Npm install failed $i, trying again..." done + workingDirectory: build env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Install build dependencies + condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) + + - script: | + set -e + export VSCODE_SYSROOT_DIR=$(Build.SourcesDirectory)/.build/sysroots/glibc-2.28-gcc-8.5.0 + SYSROOT_ARCH="amd64" VSCODE_SYSROOT_PREFIX="-glibc-2.28-gcc-8.5.0" node -e 'import { getVSCodeSysroot } from "./build/linux/debian/install-sysroot.ts"; (async () => { await getVSCodeSysroot(process.env["SYSROOT_ARCH"]); })()' + env: + VSCODE_ARCH: x64 + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Download vscode sysroots + + - script: | + set -e + + source ./build/azure-pipelines/linux/setup-env.sh + node build/npm/preinstall.ts + + for i in {1..5}; do # try 5 times + npm ci && break + if [ $i -eq 5 ]; then + echo "Npm install failed too many times" >&2 + exit 1 + fi + echo "Npm install failed $i, trying again..." + done + env: + npm_config_arch: x64 + VSCODE_ARCH: x64 ELECTRON_SKIP_BINARY_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: "$(github-distro-mixin-password)" @@ -93,43 +119,37 @@ jobs: - script: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality - - template: common/install-builtin-extensions.yml@self - - - script: npm exec -- npm-run-all2 -lp core-ci extensions-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts + - script: node build/azure-pipelines/common/checkDistroCommit.ts + displayName: Check distro commit env: GITHUB_TOKEN: "$(github-distro-mixin-password)" - displayName: Compile & Hygiene - - - script: | - set -e - - [ -d "out-build" ] || { echo "ERROR: out-build folder is missing" >&2; exit 1; } - [ -n "$(find out-build -mindepth 1 2>/dev/null | head -1)" ] || { echo "ERROR: out-build folder is empty" >&2; exit 1; } - echo "out-build exists and is not empty" + BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" + continueOnError: true + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) - ls -d out-vscode-* >/dev/null 2>&1 || { echo "ERROR: No out-vscode-* folders found" >&2; exit 1; } - for folder in out-vscode-*; do - [ -d "$folder" ] || { echo "ERROR: $folder is missing" >&2; exit 1; } - [ -n "$(find "$folder" -mindepth 1 2>/dev/null | head -1)" ] || { echo "ERROR: $folder is empty" >&2; exit 1; } - echo "$folder exists and is not empty" - done + - script: node build/azure-pipelines/common/checkCopilotChatCompatibility.ts --warn-only + displayName: Check Copilot Chat compatibility + continueOnError: true + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) - echo "All required compilation folders checked." - displayName: Validate compilation folders + - script: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile & Hygiene - - script: | - set -e - npm run compile - displayName: Compile smoke test suites (non-OSS) - workingDirectory: test/smoke - condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false')) + - script: npm run download-builtin-extensions-cg + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Download component details of built-in extensions + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) - - script: | - set -e - npm run compile - displayName: Compile integration test suites (non-OSS) - workingDirectory: test/integration/browser - condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false')) + - task: ms.vss-governance-buildtask.governance-build-task-component-detection.ComponentGovernanceComponentDetection@0 + displayName: "Component Detection" + inputs: + sourceScanPath: $(Build.SourcesDirectory) + alertWarningLevel: Medium + continueOnError: true + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) - task: AzureCLI@2 displayName: Fetch secrets @@ -142,6 +162,7 @@ jobs: Write-Host "##vso[task.setvariable variable=AZURE_TENANT_ID]$env:tenantId" Write-Host "##vso[task.setvariable variable=AZURE_CLIENT_ID]$env:servicePrincipalId" Write-Host "##vso[task.setvariable variable=AZURE_ID_TOKEN;issecret=true]$env:idToken" + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) - script: | set -e @@ -151,21 +172,4 @@ jobs: AZURE_ID_TOKEN="$(AZURE_ID_TOKEN)" \ node build/azure-pipelines/upload-sourcemaps.ts displayName: Upload sourcemaps to Azure - - - script: ./build/azure-pipelines/common/extract-telemetry.sh - displayName: Generate lists of telemetry events - - - script: tar -cz --exclude='.build/node_modules_cache' --exclude='.build/node_modules_list.txt' --exclude='.build/distro' -f $(Build.ArtifactStagingDirectory)/compilation.tar.gz $(ls -d .build out-* test/integration/browser/out test/smoke/out test/automation/out 2>/dev/null) - displayName: Compress compilation artifact - - - script: npm run download-builtin-extensions-cg - env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" - displayName: Download component details of built-in extensions - - - task: ms.vss-governance-buildtask.governance-build-task-component-detection.ComponentGovernanceComponentDetection@0 - displayName: "Component Detection" - inputs: - sourceScanPath: $(Build.SourcesDirectory) - alertWarningLevel: Medium - continueOnError: true + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) diff --git a/build/azure-pipelines/product-sanity-tests.yml b/build/azure-pipelines/product-sanity-tests.yml index ade0b96878b66..8f555f30a1f58 100644 --- a/build/azure-pipelines/product-sanity-tests.yml +++ b/build/azure-pipelines/product-sanity-tests.yml @@ -15,7 +15,6 @@ parameters: - name: buildCommit displayName: Published Build Commit type: string - default: '' - name: npmRegistry displayName: Custom NPM Registry URL @@ -28,17 +27,9 @@ variables: - name: Codeql.SkipTaskAutoInjection value: true - name: BUILD_COMMIT - ${{ if ne(parameters.buildCommit, '') }}: - value: ${{ parameters.buildCommit }} - ${{ else }}: - value: $(resources.pipeline.vscode.sourceCommit) + value: ${{ parameters.buildCommit }} - name: BUILD_QUALITY - ${{ if ne(parameters.buildCommit, '') }}: - value: ${{ parameters.buildQuality }} - ${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/heads/release/') }}: - value: stable - ${{ else }}: - value: insider + value: ${{ parameters.buildQuality }} - name: NPM_REGISTRY value: ${{ parameters.npmRegistry }} @@ -50,17 +41,6 @@ resources: type: git name: 1ESPipelineTemplates/1ESPipelineTemplates ref: refs/tags/release - pipelines: - - pipeline: vscode - # allow-any-unicode-next-line - source: '⭐️ VS Code' - trigger: - stages: - - Publish - branches: - include: - - main - - release/* extends: template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines diff --git a/build/azure-pipelines/product-validation-checks.yml b/build/azure-pipelines/product-validation-checks.yml deleted file mode 100644 index adf61f33c428c..0000000000000 --- a/build/azure-pipelines/product-validation-checks.yml +++ /dev/null @@ -1,40 +0,0 @@ -jobs: - - job: ValidationChecks - displayName: Distro and Extension Validation - timeoutInMinutes: 15 - steps: - - template: ./common/checkout.yml@self - - - task: NodeTool@0 - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - - - template: ./distro/download-distro.yml@self - - - script: node build/azure-pipelines/distro/mixin-quality.ts - displayName: Mixin distro quality - - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - - script: npm ci - workingDirectory: build - env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" - displayName: Install build dependencies - - - script: node build/azure-pipelines/common/checkDistroCommit.ts - displayName: Check distro commit - env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" - BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" - continueOnError: true - - - script: node build/azure-pipelines/common/checkCopilotChatCompatibility.ts --warn-only - displayName: Check Copilot Chat compatibility - continueOnError: true diff --git a/build/azure-pipelines/update-dependencies-check.ts b/build/azure-pipelines/update-dependencies-check.ts new file mode 100644 index 0000000000000..5923770fc2e83 --- /dev/null +++ b/build/azure-pipelines/update-dependencies-check.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import crypto from 'crypto'; +import https from 'https'; + +function createJwt(appId: string, privateKey: string): string { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId })).toString('base64url'); + const signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), privateKey).toString('base64url'); + return `${header}.${payload}.${signature}`; +} + +function request(options: https.RequestOptions, body?: object): Promise> { + return new Promise((resolve, reject) => { + const req = https.request(options, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(JSON.parse(data)); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + }); + }); + req.on('error', reject); + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +async function getInstallationToken(jwt: string, installationId: string): Promise { + const result = await request({ + hostname: 'api.github.com', + path: `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'VSCode-ADO-Pipeline', + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + return result.token as string; +} + +function updateCheckRun(token: string, checkRunId: string, conclusion: string, detailsUrl: string) { + return request({ + hostname: 'api.github.com', + path: `/repos/microsoft/vscode/check-runs/${encodeURIComponent(checkRunId)}`, + method: 'PATCH', + headers: { + 'Authorization': `token ${token}`, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'VSCode-ADO-Pipeline', + 'X-GitHub-Api-Version': '2022-11-28' + } + }, { + status: 'completed', + conclusion, + completed_at: new Date().toISOString(), + details_url: detailsUrl + }); +} + +async function main() { + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; + const installationId = process.env.GITHUB_APP_INSTALLATION_ID; + const checkRunId = process.env.CHECK_RUN_ID; + const jobStatus = process.env.AGENT_JOBSTATUS; + const detailsUrl = `${process.env.SYSTEM_COLLECTIONURI}${process.env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${process.env.BUILD_BUILDID}`; + + if (!appId || !privateKey || !installationId || !checkRunId) { + throw new Error('Missing required environment variables'); + } + + const jwt = createJwt(appId, privateKey); + const token = await getInstallationToken(jwt, installationId); + + let conclusion: string; + switch (jobStatus) { + case 'Succeeded': + case 'SucceededWithIssues': + conclusion = 'success'; + break; + case 'Canceled': + conclusion = 'cancelled'; + break; + default: + conclusion = 'failure'; + break; + } + + await updateCheckRun(token, checkRunId, conclusion, detailsUrl); + console.log(`Updated check run ${checkRunId} with conclusion: ${conclusion}`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/build/azure-pipelines/web/product-build-web.yml b/build/azure-pipelines/web/product-build-web.yml index 71932745be7fb..c9916acded34d 100644 --- a/build/azure-pipelines/web/product-build-web.yml +++ b/build/azure-pipelines/web/product-build-web.yml @@ -33,15 +33,6 @@ jobs: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: DownloadPipelineArtifact@2 - inputs: - artifact: Compilation - path: $(Build.ArtifactStagingDirectory) - displayName: Download compilation output - - - script: tar -xzf $(Build.ArtifactStagingDirectory)/compilation.tar.gz - displayName: Extract compilation output - - script: node build/setup-npm-registry.ts $NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry @@ -118,6 +109,11 @@ jobs: - template: ../common/install-builtin-extensions.yml@self + - script: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile + - script: | set -e npm run gulp vscode-web-min-ci diff --git a/build/azure-pipelines/win32/codesign.ts b/build/azure-pipelines/win32/codesign.ts index dce5e55b84069..1d51cb08c622e 100644 --- a/build/azure-pipelines/win32/codesign.ts +++ b/build/azure-pipelines/win32/codesign.ts @@ -19,7 +19,7 @@ async function main() { // 2. Codesign Powershell scripts // 3. Codesign context menu appx package (insiders only) const codesignTask1 = spawnCodesignProcess(esrpCliDLLPath, 'sign-windows', codeSigningFolderPath, '*.dll,*.exe,*.node'); - const codesignTask2 = spawnCodesignProcess(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.ps1'); + const codesignTask2 = spawnCodesignProcess(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.ps1,*.psm1,*.psd1,*.ps1xml'); const codesignTask3 = process.env['VSCODE_QUALITY'] !== 'exploration' ? spawnCodesignProcess(esrpCliDLLPath, 'sign-windows-appx', codeSigningFolderPath, '*.appx') : undefined; diff --git a/build/azure-pipelines/win32/product-build-win32-cli-sign.yml b/build/azure-pipelines/win32/product-build-win32-cli-sign.yml deleted file mode 100644 index fa1328d99e27f..0000000000000 --- a/build/azure-pipelines/win32/product-build-win32-cli-sign.yml +++ /dev/null @@ -1,83 +0,0 @@ -parameters: - - name: VSCODE_BUILD_WIN32 - type: boolean - - name: VSCODE_BUILD_WIN32_ARM64 - type: boolean - -jobs: - - job: WindowsCLISign - timeoutInMinutes: 90 - templateContext: - outputParentDirectory: $(Build.ArtifactStagingDirectory)/out - outputs: - - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_win32_x64_cli.zip - artifactName: vscode_cli_win32_x64_cli - displayName: Publish signed artifact with ID vscode_cli_win32_x64_cli - sbomBuildDropPath: $(Build.BinariesDirectory)/sign/unsigned_vscode_cli_win32_x64_cli - sbomPackageName: "VS Code Windows x64 CLI" - sbomPackageVersion: $(Build.SourceVersion) - - ${{ if eq(parameters.VSCODE_BUILD_WIN32_ARM64, true) }}: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_win32_arm64_cli.zip - artifactName: vscode_cli_win32_arm64_cli - displayName: Publish signed artifact with ID vscode_cli_win32_arm64_cli - sbomBuildDropPath: $(Build.BinariesDirectory)/sign/unsigned_vscode_cli_win32_arm64_cli - sbomPackageName: "VS Code Windows arm64 CLI" - sbomPackageVersion: $(Build.SourceVersion) - steps: - - template: ../common/checkout.yml@self - - - task: NodeTool@0 - displayName: "Use Node.js" - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - - - task: AzureKeyVault@2 - displayName: "Azure Key Vault: Get Secrets" - inputs: - azureSubscription: vscode - KeyVaultName: vscode-build-secrets - SecretsFilter: "github-distro-mixin-password" - - - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY build - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM Registry - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - # Set the private NPM registry to the global npmrc file - # so that authentication works for subfolders like build/, remote/, extensions/ etc - # which does not have their own .npmrc file - exec { npm config set registry "$env:NPM_REGISTRY" } - $NpmrcPath = (npm config get userconfig) - echo "##vso[task.setvariable variable=NPMRC_PATH]$NpmrcPath" - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM - - - task: npmAuthenticate@0 - inputs: - workingFile: $(NPMRC_PATH) - condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) - displayName: Setup NPM Authentication - - - powershell: | - . azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - exec { npm ci } - workingDirectory: build - env: - GITHUB_TOKEN: "$(github-distro-mixin-password)" - retryCountOnTaskFailure: 5 - displayName: Install build dependencies - - - template: ./steps/product-build-win32-cli-sign.yml@self - parameters: - VSCODE_CLI_ARTIFACTS: - - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: - - unsigned_vscode_cli_win32_x64_cli - - ${{ if eq(parameters.VSCODE_BUILD_WIN32_ARM64, true) }}: - - unsigned_vscode_cli_win32_arm64_cli diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index 5dd69c3b50de3..20e49d34866bf 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -9,22 +9,23 @@ parameters: jobs: - job: WindowsCLI_${{ upper(parameters.VSCODE_ARCH) }} - displayName: Windows (${{ upper(parameters.VSCODE_ARCH) }}) + displayName: Windows CLI (${{ upper(parameters.VSCODE_ARCH) }}) pool: name: 1es-windows-2022-x64 os: windows - timeoutInMinutes: 30 + timeoutInMinutes: 90 variables: VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} templateContext: outputs: - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli.zip - artifactName: unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli - displayName: Publish unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli artifact - sbomEnabled: false - isProduction: false + targetPath: $(Build.ArtifactStagingDirectory)/out/vscode_cli_win32_$(VSCODE_ARCH)_cli.zip + artifactName: vscode_cli_win32_$(VSCODE_ARCH)_cli + displayName: Publish vscode_cli_win32_$(VSCODE_ARCH)_cli artifact + sbomBuildDropPath: $(Build.ArtifactStagingDirectory)/sign + sbomPackageName: "VS Code Windows $(VSCODE_ARCH) CLI" + sbomPackageVersion: $(Build.SourceVersion) steps: - template: ../common/checkout.yml@self @@ -75,3 +76,57 @@ jobs: ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}: RUSTFLAGS: "-Ctarget-feature=+crt-static -Clink-args=/guard:cf -Clink-args=/CETCOMPAT:NO" CFLAGS: "/guard:cf /Qspectre" + + - ${{ if not(parameters.VSCODE_CHECK_ONLY) }}: + - template: ../common/publish-artifact.yml@self + parameters: + targetPath: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli.zip + artifactName: unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli + displayName: Publish unsigned CLI + sbomEnabled: false + + - task: ExtractFiles@1 + displayName: Extract unsigned CLI + inputs: + archiveFilePatterns: $(Build.ArtifactStagingDirectory)/unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli.zip + destinationFolder: $(Build.ArtifactStagingDirectory)/sign + + - task: UseDotNet@2 + inputs: + version: 6.x + + - task: EsrpCodeSigning@5 + inputs: + UseMSIAuthentication: true + ConnectedServiceName: vscode-esrp + AppRegistrationClientId: $(ESRP_CLIENT_ID) + AppRegistrationTenantId: $(ESRP_TENANT_ID) + AuthAKVName: vscode-esrp + AuthSignCertName: esrp-sign + FolderPath: . + Pattern: noop + displayName: 'Install ESRP Tooling' + + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName + $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName + echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version\net6.0\esrpcli.dll" + displayName: Find ESRP CLI + + - powershell: node build\azure-pipelines\common\sign.ts $env:EsrpCliDllPath sign-windows $(Build.ArtifactStagingDirectory)/sign "*.exe" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: ✍️ Codesign + + - powershell: Remove-Item -Path "$(Build.ArtifactStagingDirectory)/sign/CodeSignSummary*.md" -Force -ErrorAction SilentlyContinue + displayName: Remove CodeSignSummary + + - task: ArchiveFiles@2 + displayName: Archive signed CLI + inputs: + rootFolderOrFile: $(Build.ArtifactStagingDirectory)/sign + includeRootFolder: false + archiveType: zip + archiveFile: $(Build.ArtifactStagingDirectory)/out/vscode_cli_win32_$(VSCODE_ARCH)_cli.zip diff --git a/build/azure-pipelines/win32/product-build-win32.yml b/build/azure-pipelines/win32/product-build-win32.yml index 3a91d3cdd97db..9b4c4e27070ab 100644 --- a/build/azure-pipelines/win32/product-build-win32.yml +++ b/build/azure-pipelines/win32/product-build-win32.yml @@ -21,6 +21,7 @@ jobs: timeoutInMinutes: 90 variables: VSCODE_ARCH: ${{ parameters.VSCODE_ARCH }} + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory)/out outputs: diff --git a/build/azure-pipelines/win32/sdl-scan-win32.yml b/build/azure-pipelines/win32/sdl-scan-win32.yml index e3356effa95a7..2580588a7433f 100644 --- a/build/azure-pipelines/win32/sdl-scan-win32.yml +++ b/build/azure-pipelines/win32/sdl-scan-win32.yml @@ -100,7 +100,11 @@ steps: env: VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} - - powershell: npm run compile + - template: ../common/install-builtin-extensions.yml@self + + - powershell: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" displayName: Compile - powershell: npm run gulp "vscode-symbols-win32-${{ parameters.VSCODE_ARCH }}" diff --git a/build/azure-pipelines/win32/steps/product-build-win32-cli-sign.yml b/build/azure-pipelines/win32/steps/product-build-win32-cli-sign.yml deleted file mode 100644 index 0caba3d1a2b88..0000000000000 --- a/build/azure-pipelines/win32/steps/product-build-win32-cli-sign.yml +++ /dev/null @@ -1,61 +0,0 @@ -parameters: - - name: VSCODE_CLI_ARTIFACTS - type: object - default: [] - -steps: - - task: UseDotNet@2 - inputs: - version: 6.x - - - task: EsrpCodeSigning@5 - inputs: - UseMSIAuthentication: true - ConnectedServiceName: vscode-esrp - AppRegistrationClientId: $(ESRP_CLIENT_ID) - AppRegistrationTenantId: $(ESRP_TENANT_ID) - AuthAKVName: vscode-esrp - AuthSignCertName: esrp-sign - FolderPath: . - Pattern: noop - displayName: 'Install ESRP Tooling' - - - powershell: | - . build/azure-pipelines/win32/exec.ps1 - $ErrorActionPreference = "Stop" - $EsrpCodeSigningTool = (gci -directory -filter EsrpCodeSigning_* $(Agent.RootDirectory)\_tasks | Select-Object -last 1).FullName - $Version = (gci -directory $EsrpCodeSigningTool | Select-Object -last 1).FullName - echo "##vso[task.setvariable variable=EsrpCliDllPath]$Version\net6.0\esrpcli.dll" - displayName: Find ESRP CLI - - - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - - task: DownloadPipelineArtifact@2 - displayName: Download artifact - inputs: - artifact: ${{ target }} - path: $(Build.BinariesDirectory)/pkg/${{ target }} - - - task: ExtractFiles@1 - displayName: Extract artifact - inputs: - archiveFilePatterns: $(Build.BinariesDirectory)/pkg/${{ target }}/*.zip - destinationFolder: $(Build.BinariesDirectory)/sign/${{ target }} - - - powershell: node build\azure-pipelines\common\sign.ts $env:EsrpCliDllPath sign-windows $(Build.BinariesDirectory)/sign "*.exe" - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: ✍️ Codesign - - - ${{ each target in parameters.VSCODE_CLI_ARTIFACTS }}: - - powershell: | - $ASSET_ID = "${{ target }}".replace("unsigned_", ""); - echo "##vso[task.setvariable variable=ASSET_ID]$ASSET_ID" - displayName: Set asset id variable - - - task: ArchiveFiles@2 - displayName: Archive signed files - inputs: - rootFolderOrFile: $(Build.BinariesDirectory)/sign/${{ target }} - includeRootFolder: false - archiveType: zip - archiveFile: $(Build.ArtifactStagingDirectory)/out/$(ASSET_ID).zip diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index d6412c2342090..3cb6413480af8 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -37,18 +37,6 @@ steps: KeyVaultName: vscode-build-secrets SecretsFilter: "github-distro-mixin-password" - - task: DownloadPipelineArtifact@2 - inputs: - artifact: Compilation - path: $(Build.ArtifactStagingDirectory) - displayName: Download compilation output - - - task: ExtractFiles@1 - displayName: Extract compilation output - inputs: - archiveFilePatterns: "$(Build.ArtifactStagingDirectory)/compilation.tar.gz" - cleanDestinationFolder: false - - powershell: node build/setup-npm-registry.ts $env:NPM_REGISTRY condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry @@ -114,11 +102,34 @@ steps: condition: and(succeeded(), ne(variables.NODE_MODULES_RESTORED, 'true')) displayName: Create node_modules archive + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --detach --wait -- node build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact (background) + - powershell: node build/azure-pipelines/distro/mixin-quality.ts displayName: Mixin distro quality - template: ../../common/install-builtin-extensions.yml@self + - powershell: npm run gulp core-ci + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Compile + + - script: node build/azure-pipelines/common/extract-telemetry.ts + displayName: Generate lists of telemetry events + + - ${{ if or(eq(parameters.VSCODE_RUN_ELECTRON_TESTS, true), eq(parameters.VSCODE_RUN_BROWSER_TESTS, true), eq(parameters.VSCODE_RUN_REMOTE_TESTS, true)) }}: + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { npm run compile --prefix test/smoke } + exec { npm run compile --prefix test/integration/browser } + displayName: Compile test suites (non-OSS) + condition: and(succeeded(), eq(variables['VSCODE_STEP_ON_IT'], 'false')) + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: - powershell: | npm run copy-policy-dto --prefix build @@ -181,6 +192,11 @@ steps: displayName: Build server (web) - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --attach -- node build/azure-pipelines/common/waitForArtifacts.ts unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Wait for CLI artifact + - task: DownloadPipelineArtifact@2 inputs: artifact: unsigned_vscode_cli_win32_$(VSCODE_ARCH)_cli diff --git a/build/buildfile.ts b/build/buildfile.ts index 47b0476892cb7..80c97ff1daa09 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -24,6 +24,7 @@ export const workbenchDesktop = [ createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), + createModuleDescription('vs/platform/agentHost/node/agentHostMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/workbench/workbench.desktop.main'), createModuleDescription('vs/sessions/sessions.desktop.main') @@ -53,7 +54,8 @@ export const codeServer = [ // 'vs/server/node/server.cli' is not included here because it gets inlined via ./src/server-cli.js createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/platform/files/node/watcher/watcherMain'), - createModuleDescription('vs/platform/terminal/node/ptyHostMain') + createModuleDescription('vs/platform/terminal/node/ptyHostMain'), + createModuleDescription('vs/platform/agentHost/node/agentHostMain') ]; export const entrypoint = createModuleDescription; diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 3df57a48a97d2..3a0e930f3f49a 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -1a1bb622d9788793310458b7bf9eedcea8347da9556dd1d7661b757c15ebfdd5 *chromedriver-v39.6.0-darwin-arm64.zip -c84565c127adeca567ca69e85bbd8f387fff1f83c09e69f6f851528f5602dc4e *chromedriver-v39.6.0-darwin-x64.zip -f50df11f99a2e3df84560d5331608cd0a9d7a147a1490f25edfd8a95531918a2 *chromedriver-v39.6.0-linux-arm64.zip -a571fd25e33f3b3bded91506732a688319d93eb652e959bb19a09cd3f67f9e5f *chromedriver-v39.6.0-linux-armv7l.zip -2a50751190bbfe07984f7d8cbf2f12c257a4c132a36922a78c4e320169b8f498 *chromedriver-v39.6.0-linux-x64.zip -cf6034c20b727c48a6f44bb87b1ec89fd4189f56200a32cd39cedaab3f19e007 *chromedriver-v39.6.0-mas-arm64.zip -d2107db701c41fa5f3aaa04c279275ac4dcffde4542c032c806939acd8c6cd6c *chromedriver-v39.6.0-mas-x64.zip -1593ed5550fa11c549fd4ff5baea5cb7806548bff15b79340343ac24a86d6de3 *chromedriver-v39.6.0-win32-arm64.zip -deee89cbeed935a57551294fbc59f6a346b76769e27dd78a59a35a82ae3037d9 *chromedriver-v39.6.0-win32-ia32.zip -f88a23ebc246ed2a506d6d172eb9ffbb4c9d285103285a735e359268fcd08895 *chromedriver-v39.6.0-win32-x64.zip -2e1ec8568f4fda21dc4bb7231cdb0427fa31bb03c4bc39f8aa36659894f2d23e *electron-api.json -03e743428685b44beeab9aa51bad7437387dc2ce299b94745ed8fb0923dd9a07 *electron-v39.6.0-darwin-arm64-dsym-snapshot.zip -723d64530286ebd58539bc29deb65e9334ae8450a714b075d369013b4bbfdce0 *electron-v39.6.0-darwin-arm64-dsym.zip -8f529fbbed8c386f3485614fa059ea9408ebe17d3f0c793269ea52ef3efdf8df *electron-v39.6.0-darwin-arm64-symbols.zip -dace1f9e5c49f4f63f32341f8b0fb7f16b8cf07ce5fcb17abcc0b33782966b8c *electron-v39.6.0-darwin-arm64.zip -e2425514469c4382be374e676edff6779ef98ca1c679b1500337fa58aa863e98 *electron-v39.6.0-darwin-x64-dsym-snapshot.zip -877e72afd7d8695e8a4420a74765d45c30fad30606d3dbab07a0e88fe600e3f6 *electron-v39.6.0-darwin-x64-dsym.zip -ae958c150c6fe76fc7989a28ddb6104851f15d2e24bd32fe60f51e308954a816 *electron-v39.6.0-darwin-x64-symbols.zip -bed88dac3ac28249a020397d83f3f61871c7eaea2099d5bf6b1e92878cb14f19 *electron-v39.6.0-darwin-x64.zip -a86e9470d6084611f38849c9f9b3311584393fa81b55d0bbf7e284a649b729cf *electron-v39.6.0-linux-arm64-debug.zip -e7d7aec3873a6d2f2c9fe406a27a8668910f8b4fdf55a36b5302d9db3ec390db *electron-v39.6.0-linux-arm64-symbols.zip -d6ded47a49046eb031800cf70f2b5d763ccac11dac64e70a874c62aaa115ccba *electron-v39.6.0-linux-arm64.zip -2bf6a75c9f3c2400698c325e48c9b6444d108e4d76544fb130d04605002ae084 *electron-v39.6.0-linux-armv7l-debug.zip -421d02c8a063602b22e4f16a2614fe6cc13e07f9d4ead309fe40aeac296fe951 *electron-v39.6.0-linux-armv7l-symbols.zip -ee34896d1317f1572ed4f3ed8eb1719f599f250d442fc6afb6ec40091c4f4cdc *electron-v39.6.0-linux-armv7l.zip -233f55caae4514144310928248a96bd3a3ce7ac6dc1ff99e7531737a579793b1 *electron-v39.6.0-linux-x64-debug.zip -eca69e741b00ce141b9c2e6e63c1f77cd834a85aa095385f032fdb58d3154fff *electron-v39.6.0-linux-x64-symbols.zip -94bf4bee48f3c657edffd4556abbe62556ca8225cbb4528d62eb858233a3c34b *electron-v39.6.0-linux-x64.zip -6dfebeb760627df74c65ff8da7088fb77e0ae222cab5590fea4cdd37c060ea06 *electron-v39.6.0-mas-arm64-dsym-snapshot.zip -b327d41507546799451a684b6061caed10f1c16ee39a7e686aac71187f8b7afe *electron-v39.6.0-mas-arm64-dsym.zip -02a56a9c3c3522ebc653f03ad88be9a2f46594c730a767a28e7322ddb7a789b7 *electron-v39.6.0-mas-arm64-symbols.zip -2fe93cd39521371bb5722c358feebadc5e79d79628b07a79a00a9d918e261de4 *electron-v39.6.0-mas-arm64.zip -f25ddc8a9b2b699d6d9e54fdf66220514e387ae36e45efeb4d8217b1462503f6 *electron-v39.6.0-mas-x64-dsym-snapshot.zip -6732026b6a3728bea928af0c5928bf82d565eebeb3f5dc5b6991639d27e7c457 *electron-v39.6.0-mas-x64-dsym.zip -5260dabf5b0fc369e0f69d3286fbcce9d67bc65e3364e17f7bb13dd49e320422 *electron-v39.6.0-mas-x64-symbols.zip -905f7cf95270afa92972b6c9242fc50c0afd65ffd475a81ded6033588f27a613 *electron-v39.6.0-mas-x64.zip -9204c9844e89f5ca0b32a8347cf9141d8dcb66671906e299afa06004f464d9b0 *electron-v39.6.0-win32-arm64-pdb.zip -6778c54d8cf7a0d305e4334501c3b877daf4737197187120ac18064f4e093b23 *electron-v39.6.0-win32-arm64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-arm64-toolchain-profile.zip -22b96aca4cf8f7823b98e3b20b6131e521e0100c5cd03ab76f106eefbd0399cf *electron-v39.6.0-win32-arm64.zip -f5b69c8c1c9349a1f3b4309fb3fa1cf6326953e0807d2063fc27ba9f1400232e *electron-v39.6.0-win32-ia32-pdb.zip -1d6e103869acdeb0330b26ee08089667e0b5afc506efcd7021ba761ed8b786b5 *electron-v39.6.0-win32-ia32-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-ia32-toolchain-profile.zip -2b30e5bc923fff1443e2a4d1971cb9b26f61bd6a454cfbb991042457bab4d623 *electron-v39.6.0-win32-ia32.zip -5f93924c317206a2a4800628854e44e68662a9c40b3457c9e72690d6fff884d3 *electron-v39.6.0-win32-x64-pdb.zip -eab07439f0a21210cd560c1169c04ea5e23c6fe0ab65bd60cffce2b9f69fd36e *electron-v39.6.0-win32-x64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-x64-toolchain-profile.zip -e8eee36be3bb85ba6fd8fcd26cf3a264bc946ac0717762c64e168896695c8e34 *electron-v39.6.0-win32-x64.zip -2e84c606e40c7bab5530e4c83bbf3a24c28143b0a768dafa5ecf78b18d889297 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.6.0-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.6.0-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.6.0-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-mas-x64.zip -2a358c2dbeeb259c0b6a18057b52ffb0109de69112086cb2ce02f3a79bd70cee *ffmpeg-v39.6.0-win32-arm64.zip -4555510880a7b8dff5d5d0520f641665c62494689782adbed67fa0e24b45ae67 *ffmpeg-v39.6.0-win32-ia32.zip -091ab3c97d5a1cda1e04c6bd263a2c07ea63ed7ec3fd06600af6d7e23bbbbe15 *ffmpeg-v39.6.0-win32-x64.zip -650fb5fbc7e6cc27e5caeb016f72aba756469772bbfdfb3ec0b229f973d8ad46 *hunspell_dictionaries.zip -669ef1bf8ed0f6378e67f4f8bc23d2907d7cc1db7369dbdf468e164f4ef49365 *libcxx-objects-v39.6.0-linux-arm64.zip -996d81ad796524246144e15e22ffef75faff055a102c49021d70b03f039c3541 *libcxx-objects-v39.6.0-linux-armv7l.zip -1ffb610613c11169640fa76e4790137034a0deb3b48e2aef51a01c9b96b7700a *libcxx-objects-v39.6.0-linux-x64.zip -6dd8db57473992367c7914b50d06cae3a1b713cc09ceebecfcd4107df333e759 *libcxx_headers.zip -e5c18f813cc64a7d3b0404ee9adeb9cbb49e7ee5e1054b62c71fa7d1a448ad1b *libcxxabi_headers.zip -7f58d6e1d8c75b990f7d2259de8d0896414d0f2cff2f0fe4e5c7f8037d8fe879 *mksnapshot-v39.6.0-darwin-arm64.zip -be1178e4aa1f4910ba2b8f35b5655e12182657b9e32d509b47f0b2db033f0ac5 *mksnapshot-v39.6.0-darwin-x64.zip -5e36a594067fea08bb3d7bcd60873c3e240ebcee2208bcebfbc9f77d3075cc0d *mksnapshot-v39.6.0-linux-arm64-x64.zip -2db9196d2af0148ebb7b6f1f597f46a535b7af482f95739bd1ced78e1ebf39e7 *mksnapshot-v39.6.0-linux-armv7l-x64.zip -cd673e0a908fc950e0b4246e2b099018a8ee879d12a62973a01cb7de522f5bcf *mksnapshot-v39.6.0-linux-x64.zip -0749d8735a1fd8c666862cd7020b81317c45203d01319c9be089d1e750cb2c15 *mksnapshot-v39.6.0-mas-arm64.zip -81ae98e064485f8c6c69cd6c875ee72666c0cc801a8549620d382c2d0cea3b5c *mksnapshot-v39.6.0-mas-x64.zip -2e44f75df797922e7c8bad61a1b41fed14b070a54257a6a751892b2b8b9dfe29 *mksnapshot-v39.6.0-win32-arm64-x64.zip -fb5d73a8bf4b8db80f61b7073aa8458b5c46cce5c2a4b23591e851c6fcbd0144 *mksnapshot-v39.6.0-win32-ia32.zip -118ae88dbcd6b260cfa370e46ccfb0ab00af5efbf59495aaeea56a2831f604b2 *mksnapshot-v39.6.0-win32-x64.zip +0ab48e3e8888b5c33950be0c36da939aa989df7609d3c32140c5e5371ea53abb *chromedriver-v39.8.3-darwin-arm64.zip +b7103565ffb4068dc705c50ce3039ed3178cac350301abf82545de54ac3bc849 *chromedriver-v39.8.3-darwin-x64.zip +e7e43ee7a3d14482ce488d0b0bc338a026a00ee544e5a3d55aed220af6b5da0e *chromedriver-v39.8.3-linux-arm64.zip +060223baebe6d8f9e8c7367bf561dd558fca03509edcc3bce660c42f96ad73ea *chromedriver-v39.8.3-linux-armv7l.zip +854a6f921684e59866aed9db0e9f61d28f756f70b7898f947359b4d04dba76db *chromedriver-v39.8.3-linux-x64.zip +f70ea58bc5e4e51eec51f65e153cfd36eea568ecd571c2815a4c05a457b6923d *chromedriver-v39.8.3-mas-arm64.zip +8e3e1450bc544bff712ffab0ba365d1ed2c9b79116b4ec4750a46c8607242ed4 *chromedriver-v39.8.3-mas-x64.zip +c07e35a2a5a673c8902452571f3436ca8b774fa4628ad9e42f179d3c935f4ed7 *chromedriver-v39.8.3-win32-arm64.zip +d0361344208d8bdf58500d08ae1bb723b9ccdc66fc736c2fc6c9f011bcc6e47d *chromedriver-v39.8.3-win32-ia32.zip +e2e91fd7d97e3e9806d22c4990253cbd5e466cdfa1a8e4c86c72431f7d3a8d0f *chromedriver-v39.8.3-win32-x64.zip +f004c879e159edf3eb403bd43bc76c3069b0b375c6dfae5b249b96d543e51e26 *electron-api.json +21a5324aaed782fead97b2e50f833373148392d4c13ec818f80f142e800c6158 *electron-v39.8.3-darwin-arm64-dsym-snapshot.zip +bb9c14900f48aabb7d272149ba4b60813b366f1e67f95b510da73355e15ba78c *electron-v39.8.3-darwin-arm64-dsym.zip +8a42b50a0841e7bfefc49e704f5cfdb3cbb7b9a507ac74b9982004a9350a202d *electron-v39.8.3-darwin-arm64-symbols.zip +e1b9e03a56fc27ad567c8d2bb32a21e0e2afe6a095f71c26df5b8b8ed8dd8d4c *electron-v39.8.3-darwin-arm64.zip +5b474116e398286a80def6509fa255481ab88fbb52b1770dfd5d39ddff124c6b *electron-v39.8.3-darwin-x64-dsym-snapshot.zip +14648a98eef5a28c1158f0580a813617d9ce6d77a8b7881c389acfff34d328cd *electron-v39.8.3-darwin-x64-dsym.zip +231e13b26c39cceecec359e74c00e4d6a13de3ae9fb6459f18846f91f214074f *electron-v39.8.3-darwin-x64-symbols.zip +22cf6f6147d5d632e2a8ad5207504a18db94a8c96e3f4f65f792822eaed7bf1c *electron-v39.8.3-darwin-x64.zip +fdf25df8857e1ef2cdb0a5be71b78dfb9923a6061cf11336577c6a4368ecfdcd *electron-v39.8.3-linux-arm64-debug.zip +731bf3f908a1efe871e862852087b67027c791427284f057d42376634d4d53d3 *electron-v39.8.3-linux-arm64-symbols.zip +e1a0e6939fe2d10c1f807888f74dbbb9f28a2cfc25e28bb8168f5513513fc124 *electron-v39.8.3-linux-arm64.zip +65893fd03097eadb0c89eb95ded97e97a9910bbc53634f12170cfb40b9165832 *electron-v39.8.3-linux-armv7l-debug.zip +997acce3540d16f9e0551cde811021999a4276c970bfe42ed77c3fd769ba6d05 *electron-v39.8.3-linux-armv7l-symbols.zip +5d5825966a3b2678c50121c81ed3fb8c39d35c3798dd0413a19afaac04109ef9 *electron-v39.8.3-linux-armv7l.zip +52b44ef60f73ef7b7c8461f520a1048da3601d9cc869262ec63f507cd6591e78 *electron-v39.8.3-linux-x64-debug.zip +c4e1fa21d21724ab7f5bcdb6c1bfc03dca837ebcca00d6af56944041499d35a9 *electron-v39.8.3-linux-x64-symbols.zip +5866d6c6f8fcf15967279107d2387edfa4589c5a00ad52d4b770d7504106a734 *electron-v39.8.3-linux-x64.zip +3ff4c9fb99f40dda465486fa6fa23eaedd89b87dcd9cce402a171accfcacc9bd *electron-v39.8.3-mas-arm64-dsym-snapshot.zip +9401101eabaf5e55063b9fad94bf3ac2fe9d743ff88ec638a3c6c665b2266564 *electron-v39.8.3-mas-arm64-dsym.zip +0905e57da501b64436dff51b1378bae311cb1276372dc39dedb7aef44f1b947a *electron-v39.8.3-mas-arm64-symbols.zip +1af2cdee3405c0b8e1c8145a65891b249ac737dd35d959cebd6833970ad5eb08 *electron-v39.8.3-mas-arm64.zip +9e8c6b7b880ac726cda52aaf2b02bc9f0750559be85ef1583789d52b617914b9 *electron-v39.8.3-mas-x64-dsym-snapshot.zip +483ce280606a61c7ed4394e99008ad6fc7e2ce9c35149c5ad745bce9ee78a7a2 *electron-v39.8.3-mas-x64-dsym.zip +1e16529ab1ee8404b1623df611077abcbbbabc1f825a57e93e2ef2b1f7ba788a *electron-v39.8.3-mas-x64-symbols.zip +19df399b352db2c3b3f26f830700b17adf697b70e4d361b1e0f20790e6e703b6 *electron-v39.8.3-mas-x64.zip +bdfd01e7c55ea4beb90afd4285ab3639e3a66808ec993389e9eaf62c3edcb5e9 *electron-v39.8.3-win32-arm64-pdb.zip +7329264c9d308a78081509cb4173f0bd931522655d2f434ad858555e735e5721 *electron-v39.8.3-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-arm64-toolchain-profile.zip +699933ff8c4d7216fc0318f239a5f593f06487c0dc9c3722b8744f6a44fca94e *electron-v39.8.3-win32-arm64.zip +1ffab5a8419a1a93476e2edc09754a52bbe9f3d39915e097f2a1ee50ffdbbd13 *electron-v39.8.3-win32-ia32-pdb.zip +6445047728d64a09db80205c24135f140ba60c25433d833f581c57092638b875 *electron-v39.8.3-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-ia32-toolchain-profile.zip +b80bb96a4eda2c2b6bd223d2d8b6abfc39abdebac0b36cf74cd70661d43258a5 *electron-v39.8.3-win32-ia32.zip +c8e3cab205bdfe42f916a4428fe0a5e88b6f90e8482e297dadfe1234420abb8f *electron-v39.8.3-win32-x64-pdb.zip +eda4a2f01e388eccfc2ecc7587b0987d123ae01e5b832b73a0a76bb62680bd7c *electron-v39.8.3-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-x64-toolchain-profile.zip +12eabd7c5f08823525034c1ff3ab286f271af802928e0f224b458235e2689c5a *electron-v39.8.3-win32-x64.zip +d5345fc0cb336a425f7a25885f67969452746cbf30cc1e95449f7a68221aab07 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.3-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.3-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.3-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-mas-x64.zip +06d402e51bf66fd1a0eddc7e8329b31eb8a1bc6c829e5bc13694708a010feb07 *ffmpeg-v39.8.3-win32-arm64.zip +4b1c2ddedebbf32b7792fb788ddce577f2dc6f8ecccd913e72e842068b2f81e5 *ffmpeg-v39.8.3-win32-ia32.zip +0cf0f521a452d6fdfe1313b81284d646e991954e91c356d805f5c066f2ecf278 *ffmpeg-v39.8.3-win32-x64.zip +bb2d6ec64c43e5a41da5bc55a3daa2b7ef8a0dee722dc73f4fa31bcae5487cb2 *hunspell_dictionaries.zip +7fd663a5eeaf4d0a93785ea4ea715d21464c70c1341b7d8629c96a7bfe24044a *libcxx-objects-v39.8.3-linux-arm64.zip +bdd7e9f732b97b6be2c8293d9391bce3a5cd60feae1c762a0dda0790493da7a2 *libcxx-objects-v39.8.3-linux-armv7l.zip +0de009f84fed7c1bba087ff161674177ca91951fca2f4c60850be0bffb42dfdf *libcxx-objects-v39.8.3-linux-x64.zip +8ae7fd5c3bdc332f9f49830a9316e470d43f17e6ad6adbd05ac629d03d1718c2 *libcxx_headers.zip +9b988e2bb379c6d3094872f600944ad3284510cf225f86368c4f43270b89673c *libcxxabi_headers.zip +d504296ed183e3f460028a73b4a5e2bcd99bc4a3c74b8dc73ba987719c005458 *mksnapshot-v39.8.3-darwin-arm64.zip +b18b8a0e902cf86961d53486826fb07feb3ac98e018b2849cf2bb13150077b13 *mksnapshot-v39.8.3-darwin-x64.zip +7d9dc2ceb3f88d8d532af5b90387479ade571a0370489429571871d386c12322 *mksnapshot-v39.8.3-linux-arm64-x64.zip +84114ba67259f52ae462210544e815b602d70b71162f7f70982a7c36db54b4fa *mksnapshot-v39.8.3-linux-armv7l-x64.zip +be08392f0964d2166bb84212d225c5380fde9b12e622599cb040f45524ff7882 *mksnapshot-v39.8.3-linux-x64.zip +ffebb01a6fe568ec51391f9585e8abf1a93a566a7c991b2abacc33cc2e94d705 *mksnapshot-v39.8.3-mas-arm64.zip +858a8ee80b6f826b1d24a9458b8acb0fcc9805ee3c309652d60ed5c07b578113 *mksnapshot-v39.8.3-mas-x64.zip +dbc82c573f1ba098b6d321ad79c6580f27066d94e13fd93c0b7650a54150eb5c *mksnapshot-v39.8.3-win32-arm64-x64.zip +c8f7c43741b20da99558db596d32c86ebff3483aaf6b3e2539cd85a471b92043 *mksnapshot-v39.8.3-win32-ia32.zip +000941eeb8e1169d581120df7d42aa0b76e33de99a06528a9c4767bcffb74cbf *mksnapshot-v39.8.3-win32-x64.zip diff --git a/build/checksums/nodejs.txt b/build/checksums/nodejs.txt index c5cb12c79729a..5b1c61efa076f 100644 --- a/build/checksums/nodejs.txt +++ b/build/checksums/nodejs.txt @@ -1,7 +1,7 @@ -5ed4db0fcf1eaf84d91ad12462631d73bf4576c1377e192d222e48026a902640 node-v22.22.0-darwin-arm64.tar.gz -5ea50c9d6dea3dfa3abb66b2656f7a4e1c8cef23432b558d45fb538c7b5dedce node-v22.22.0-darwin-x64.tar.gz -25ba95dfb96871fa2ef977f11f95ea90818c8fa15c0f2110771db08d4ba423be node-v22.22.0-linux-arm64.tar.gz -a92684d8720589f19776fb186c5a3a4d273c13436fc8c44b61dd3eeef81f0d3a node-v22.22.0-linux-armv7l.tar.gz -c33c39ed9c80deddde77c960d00119918b9e352426fd604ba41638d6526a4744 node-v22.22.0-linux-x64.tar.gz -fd44256121597d6a3707f4c7730b4e3733eacb5a95cc78a099f601d7e7f8290d win-arm64/node.exe -bae898add4643fcf890a83ad8ae56e20dce7e781cab161a53991ceba70c99ffb win-x64/node.exe +679ad4966339e4ef4900f57996714864e4211b898825bb840c3086c419fbcef2 node-v22.22.1-darwin-arm64.tar.gz +07b13722d558790fca20bb1ecf61bde24b7a4863111f7be77fc57251a407359a node-v22.22.1-darwin-x64.tar.gz +1d1690e9aba47e887a275abc6d8f7317e571a0700deaef493f768377e99155f5 node-v22.22.1-linux-arm64.tar.gz +2b592d21609ef299d1e3918bb806ed62ba715d4109b0f8ec11b132af9fa42d70 node-v22.22.1-linux-armv7l.tar.gz +07c8aafa60644fb81adefa1ee7da860eb1920851ffdc9a37020ab0be47fbc10e node-v22.22.1-linux-x64.tar.gz +993b56091266aec4a41653ea3e70b5b18fadc78952030ca0329309240030859c win-arm64/node.exe +923a41f268ab49ede2e3363fbdd9e790609e385c6f3ca880b4ee9a56a8133e5a win-x64/node.exe diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 26aead0ca19dd..46544b0c4d92d 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -10,6 +10,30 @@ import { makeUniversalApp } from 'vscode-universal-bundler'; const root = path.dirname(path.dirname(import.meta.dirname)); +const nodeModulesBases = [ + path.join('Contents', 'Resources', 'app', 'node_modules'), + path.join('Contents', 'Resources', 'app', 'node_modules.asar.unpacked'), +]; + +/** + * Ensures a directory exists in both the x64 and arm64 app bundles by copying + * it from whichever build has it to the one that does not. This is needed for + * platform-specific native module directories that npm only installs for the + * host architecture. + */ +function crossCopyPlatformDir(x64AppPath: string, arm64AppPath: string, relativePath: string): void { + const inX64 = path.join(x64AppPath, relativePath); + const inArm64 = path.join(arm64AppPath, relativePath); + + if (fs.existsSync(inX64) && !fs.existsSync(inArm64)) { + fs.mkdirSync(inArm64, { recursive: true }); + fs.cpSync(inX64, inArm64, { recursive: true }); + } else if (fs.existsSync(inArm64) && !fs.existsSync(inX64)) { + fs.mkdirSync(inX64, { recursive: true }); + fs.cpSync(inArm64, inX64, { recursive: true }); + } +} + async function main(buildDir?: string) { const arch = process.env['VSCODE_ARCH']; @@ -25,10 +49,33 @@ async function main(buildDir?: string) { const outAppPath = path.join(buildDir, `VSCode-darwin-${arch}`, appName); const productJsonPath = path.resolve(outAppPath, 'Contents', 'Resources', 'app', 'product.json'); + // Copilot SDK ships platform-specific native binaries that npm only installs + // for the host architecture. The universal app merger requires both builds to + // have identical file trees, so we cross-copy each missing directory from the + // other build. The binaries are then excluded from comparison (filesToSkip) + // and the x64 binary is tagged as arch-specific (x64ArchFiles) so the merger + // keeps both. + for (const plat of ['darwin-x64', 'darwin-arm64']) { + for (const base of nodeModulesBases) { + // @github/copilot-{platform} packages (e.g. copilot-darwin-x64) + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@github', `copilot-${plat}`)); + // @github/copilot/prebuilds/{platform} (pty.node, spawn-helper) + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@github', 'copilot', 'prebuilds', plat)); + } + } + const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', '**/policies/{*.mobileconfig,**/*.plist}', + '**/node_modules/@github/copilot-darwin-x64/**', + '**/node_modules/@github/copilot-darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-arm64/**', + '**/node_modules/@github/copilot/prebuilds/darwin-x64/**', + '**/node_modules/@github/copilot/prebuilds/darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-arm64/**', ]; await makeUniversalApp({ @@ -38,7 +85,7 @@ async function main(buildDir?: string) { outAppPath, force: true, mergeASARs: true, - x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node}', + x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node,**/node_modules/@github/copilot-darwin-*/copilot,**/node_modules/@github/copilot/prebuilds/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot-darwin-*/copilot,**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/*}', filesToSkipComparison: (file: string) => { for (const expected of filesToSkip) { if (minimatch(file, expected)) { diff --git a/build/darwin/dmg-settings.py.template b/build/darwin/dmg-settings.py.template index 4a54a69ab0264..f471029f32a2a 100644 --- a/build/darwin/dmg-settings.py.template +++ b/build/darwin/dmg-settings.py.template @@ -6,8 +6,9 @@ format = 'ULMO' badge_icon = {{BADGE_ICON}} background = {{BACKGROUND}} -# Volume size (None = auto-calculate) -size = None +# Volume size +size = '1g' +shrink = False # Files and symlinks files = [{{APP_PATH}}] diff --git a/build/darwin/verify-macho.ts b/build/darwin/verify-macho.ts index 7770b9c36cd1f..bec37b2dd8e5f 100644 --- a/build/darwin/verify-macho.ts +++ b/build/darwin/verify-macho.ts @@ -26,6 +26,14 @@ const FILES_TO_SKIP = [ // MSAL runtime files are only present in ARM64 builds '**/extensions/microsoft-authentication/dist/libmsalruntime.dylib', '**/extensions/microsoft-authentication/dist/msal-node-runtime.node', + // Copilot SDK: universal app has both x64 and arm64 platform packages + '**/node_modules/@github/copilot-darwin-x64/**', + '**/node_modules/@github/copilot-darwin-arm64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-x64/**', + '**/node_modules.asar.unpacked/@github/copilot-darwin-arm64/**', + // Copilot prebuilds: single-arch binaries in per-platform directories + '**/node_modules/@github/copilot/prebuilds/darwin-*/**', + '**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/**', ]; function isFileSkipped(file: string): boolean { diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index a2eb47535f4dd..e0137816c8c92 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -95,6 +95,7 @@ const compilations = [ '.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json', '.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json', + '.vscode/extensions/vscode-extras/tsconfig.json', ]; const getBaseUrl = (out: string) => `https://main.vscode-cdn.net/sourcemaps/${commit}/${out}`; @@ -289,19 +290,7 @@ export const compileAllExtensionsBuildTask = task.define('compile-extensions-bui )); gulp.task(compileAllExtensionsBuildTask); -// This task is run in the compilation stage of the CI pipeline. We only compile the non-native extensions since those can be fully built regardless of platform. -// This defers the native extensions to the platform specific stage of the CI pipeline. -gulp.task(task.define('extensions-ci', task.series(compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask))); -const compileExtensionsBuildPullRequestTask = task.define('compile-extensions-build-pr', task.series( - cleanExtensionsBuildTask, - bundleMarketplaceExtensionsBuildTask, - task.define('bundle-extensions-build-pr', () => ext.packageAllLocalExtensionsStream(false, true).pipe(gulp.dest('.build'))), -)); -gulp.task(compileExtensionsBuildPullRequestTask); - -// This task is run in the compilation stage of the PR pipeline. We compile all extensions in it to verify compilation. -gulp.task(task.define('extensions-ci-pr', task.series(compileExtensionsBuildPullRequestTask, compileExtensionMediaBuildTask))); //#endregion @@ -320,13 +309,6 @@ async function buildWebExtensions(isWatch: boolean): Promise { { ignore: ['**/node_modules'] } ); - // Find all webpack configs, excluding those that will be esbuilt - const esbuildExtensionDirs = new Set(esbuildConfigLocations.map(p => path.dirname(p))); - const webpackConfigLocations = (await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), - { ignore: ['**/node_modules'] } - )).filter(configPath => !esbuildExtensionDirs.has(path.dirname(configPath))); - const promises: Promise[] = []; // Esbuild for extensions @@ -341,10 +323,5 @@ async function buildWebExtensions(isWatch: boolean): Promise { ); } - // Run webpack for remaining extensions - if (webpackConfigLocations.length > 0) { - promises.push(ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath })))); - } - await Promise.all(promises); } diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 8e7f6bbbdca7e..f3ba46d9f8967 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -34,6 +34,7 @@ import * as cp from 'child_process'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls, fetchGithub } from './lib/fetch.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; import jsonEditor from 'gulp-json-editor'; @@ -83,6 +84,7 @@ const serverResourceIncludes = [ 'out-build/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh', 'out-build/vs/workbench/contrib/terminal/common/scripts/shellIntegration-login.zsh', 'out-build/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish', + 'out-build/vs/workbench/contrib/terminal/common/scripts/psreadline/**', ]; @@ -342,6 +344,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.stripSourceMappingURL()) .pipe(jsFilter.restore); @@ -460,6 +463,13 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } +function copyCopilotNativeDepsTaskREH(platform: string, arch: string, destinationFolderName: string) { + return async () => { + const nodeModulesDir = path.join(BUILD_ROOT, destinationFolderName, 'node_modules'); + copyCopilotNativeDeps(platform, arch, nodeModulesDir); + }; +} + /** * @param product The parsed product.json file contents */ @@ -508,7 +518,8 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { compileNativeExtensionsBuildTask, gulp.task(`node-${platform}-${arch}`) as task.Task, util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), - packageTask(type, platform, arch, sourceFolderName, destinationFolderName) + packageTask(type, platform, arch, sourceFolderName, destinationFolderName), + copyCopilotNativeDepsTaskREH(platform, arch, destinationFolderName) ]; if (platform === 'win32') { diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c50bdfcda3f7c..336a8947fbb56 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,6 +31,8 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; +import type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; @@ -42,8 +44,6 @@ const glob = promisify(globCallback); const rcedit = promisify(rceditCallback); const root = path.dirname(import.meta.dirname); const commit = getVersion(root); -const useVersionedUpdate = process.platform === 'win32' && (product as typeof product & { win32VersionedUpdate?: boolean })?.win32VersionedUpdate; -const versionedResourcesFolder = useVersionedUpdate ? commit!.substring(0, 10) : ''; // Build const vscodeEntryPoints = [ @@ -90,6 +90,7 @@ const vscodeResourceIncludes = [ 'out-build/vs/workbench/contrib/terminal/common/scripts/*.psm1', 'out-build/vs/workbench/contrib/terminal/common/scripts/*.sh', 'out-build/vs/workbench/contrib/terminal/common/scripts/*.zsh', + 'out-build/vs/workbench/contrib/terminal/common/scripts/psreadline/**', // Accessibility Signals 'out-build/vs/platform/accessibilitySignal/browser/media/*.mp3', @@ -99,6 +100,8 @@ const vscodeResourceIncludes = [ // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + 'out-build/vs/sessions/prompts/*.prompt.md', + 'out-build/vs/sessions/skills/**/SKILL.md', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', @@ -236,6 +239,9 @@ function runTsGoTypeCheck(): Promise { } const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; +const isCI = !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; +const useCdnSourceMapsForPackagingTasks = isCI; +const stripSourceMapsInPackagingTasks = isCI; const minifyVSCodeTask = task.define('minify-vscode', task.series( bundleVSCodeTask, util.rimraf('out-vscode-min'), @@ -243,19 +249,17 @@ const minifyVSCodeTask = task.define('minify-vscode', task.series( )); gulp.task(minifyVSCodeTask); -const coreCIOld = task.define('core-ci-old', task.series( +gulp.task(task.define('core-ci-old', task.series( gulp.task('compile-build-with-mangling') as task.Task, task.parallel( gulp.task('minify-vscode') as task.Task, gulp.task('minify-vscode-reh') as task.Task, gulp.task('minify-vscode-reh-web') as task.Task, ) -)); -gulp.task(coreCIOld); +))); -const coreCIEsbuild = task.define('core-ci-esbuild', task.series( +gulp.task(task.define('core-ci', task.series( copyCodiconsTask, - cleanExtensionsBuildTask, compileNonNativeExtensionsBuildTask, compileExtensionMediaBuildTask, writeISODate('out-build'), @@ -269,10 +273,7 @@ const coreCIEsbuild = task.define('core-ci-esbuild', task.series( task.define('esbuild-vscode-reh-min', () => runEsbuildBundle('out-vscode-reh-min', true, true, 'server', `${sourceMappingURLBase}/core`)), task.define('esbuild-vscode-reh-web-min', () => runEsbuildBundle('out-vscode-reh-web-min', true, true, 'server-web', `${sourceMappingURLBase}/core`)), ) -)); -gulp.task(coreCIEsbuild); - -gulp.task(task.define('core-ci', useEsbuildTranspile ? coreCIEsbuild : coreCIOld)); +))); const coreCIPR = task.define('core-ci-pr', task.series( gulp.task('compile-build-without-mangling') as task.Task, @@ -324,6 +325,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const task = () => { const out = sourceFolderName; + const versionedResourcesFolder = util.getVersionedResourcesFolder(platform, commit!); const checksums = computeChecksums(out, [ 'vs/base/parts/sandbox/electron-browser/preload.js', @@ -353,8 +355,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const extensions = gulp.src(['.build/extensions/**', ...platformSpecificBuiltInExtensionsExclusions], { base: '.build', dot: true }); + const sourceFilterPattern = stripSourceMapsInPackagingTasks + ? ['**', '!**/*.{js,css}.map'] + : ['**']; const sources = es.merge(src, extensions) - .pipe(filter(['**', '!**/*.{js,css}.map'], { dot: true })); + .pipe(filter(sourceFilterPattern, { dot: true })); let version = packageJson.version; const quality = (product as { quality?: string }).quality; @@ -389,7 +394,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; const embedded = isInsiderOrExploration - ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string; urlProtocol: string } }).embedded + ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; const packageSubJsonStream = isInsiderOrExploration @@ -404,12 +409,9 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const productSubJsonStream = embedded ? gulp.src(['product.json'], { base: '.' }) .pipe(jsonEditor((json: Record) => { - json.nameShort = embedded.nameShort; - json.nameLong = embedded.nameLong; - json.applicationName = embedded.applicationName; - json.dataFolderName = embedded.dataFolderName; - json.darwinBundleIdentifier = embedded.darwinBundleIdentifier; - json.urlProtocol = embedded.urlProtocol; + Object.keys(embedded).forEach(key => { + json[key] = embedded[key as keyof EmbeddedProductInfo]; + }); return json; })) .pipe(rename('product.sub.json')) @@ -427,16 +429,23 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const productionDependencies = getProductionDependencies(root); const dependenciesSrc = productionDependencies.map(d => path.relative(root, d)).map(d => [`${d}/**`, `!${d}/**/{test,tests}/**`]).flat().concat('!**/*.mk'); + const depFilterPattern = ['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock']; + if (stripSourceMapsInPackagingTasks) { + depFilterPattern.push('!**/*.{js,css}.map'); + } + const deps = gulp.src(dependenciesSrc, { base: '.', dot: true }) - .pipe(filter(['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock', '!**/*.{js,css}.map'])) + .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) .pipe(createAsar(path.join(process.cwd(), 'node_modules'), [ '**/*.node', '**/@vscode/ripgrep/bin/*', + '**/@github/copilot-*/**', '**/node-pty/build/Release/*', '**/node-pty/build/Release/conpty/*', '**/node-pty/lib/worker/conoutSocketWorker.js', @@ -500,6 +509,9 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'resources/win32/code_70x70.png', 'resources/win32/code_150x150.png' ], { base: '.' })); + if (embedded) { + all = es.merge(all, gulp.src('resources/win32/sessions.ico', { base: '.' })); + } } else if (platform === 'linux') { const policyDest = gulp.src('.build/policies/linux/**', { base: '.build/policies/linux' }) .pipe(rename(f => f.dirname = `policies/${f.dirname}`)); @@ -523,6 +535,14 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d darwinMiniAppName: embedded.nameShort, darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, darwinMiniAppIcon: 'resources/darwin/sessions.icns', + darwinMiniAppAssetsCar: 'resources/darwin/sessions.car', + darwinMiniAppBundleURLTypes: [{ + role: 'Viewer', + name: embedded.nameLong, + urlSchemes: [embedded.urlProtocol] + }], + win32ProxyAppName: embedded.nameShort, + win32ProxyIcon: 'resources/win32/sessions.ico', } : {}) }; @@ -531,7 +551,13 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(util.fixWin32DirectoryPermissions()) .pipe(filter(['**', '!**/.github/**'], { dot: true })) // https://github.com/microsoft/vscode/issues/116523 .pipe(electron(electronConfig)) - .pipe(filter(['**', '!LICENSE', '!version'], { dot: true })); + .pipe(filter([ + '**', + '!LICENSE', + '!version', + ...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []), + ...(platform === 'win32' && !isInsiderOrExploration ? ['!**/electron_proxy.exe'] : []), + ], { dot: true })); if (platform === 'linux') { result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) @@ -546,7 +572,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d if (platform === 'win32') { result = es.merge(result, gulp.src('resources/win32/bin/code.js', { base: 'resources/win32', allowEmpty: true })); - if (useVersionedUpdate) { + if (versionedResourcesFolder) { result = es.merge(result, gulp.src('resources/win32/versioned/bin/code.cmd', { base: 'resources/win32/versioned' }) .pipe(replace('@@NAME@@', product.nameShort)) .pipe(replace('@@VERSIONFOLDER@@', versionedResourcesFolder)) @@ -579,6 +605,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d } result = es.merge(result, gulp.src('resources/win32/VisualElementsManifest.xml', { base: 'resources/win32' }) + .pipe(replace('@@VERSIONFOLDER@@', versionedResourcesFolder ? `${versionedResourcesFolder}\\` : '')) .pipe(rename(product.nameShort + '.VisualElementsManifest.xml'))); result = es.merge(result, gulp.src('.build/policies/win32/**', { base: '.build/policies/win32' }) @@ -623,6 +650,7 @@ function patchWin32DependenciesTask(destinationFolderName: string) { const cwd = path.join(path.dirname(root), destinationFolderName); return async () => { + const versionedResourcesFolder = util.getVersionedResourcesFolder('win32', commit!); const deps = (await Promise.all([ glob('**/*.node', { cwd, ignore: 'extensions/node_modules/@parcel/watcher/**' }), glob('**/rg.exe', { cwd }), @@ -654,6 +682,21 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } +function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) { + const outputDir = path.join(path.dirname(root), destinationFolderName); + + return async () => { + // On Windows with win32VersionedUpdate, app resources live under a + // commit-hash prefix: {output}/{commitHash}/resources/app/ + const versionedResourcesFolder = util.getVersionedResourcesFolder(platform, commit!); + const appBase = platform === 'darwin' + ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app') + : path.join(outputDir, versionedResourcesFolder, 'resources', 'app'); + + copyCopilotNativeDeps(platform, arch, path.join(appBase, 'node_modules')); + }; +} + const buildRoot = path.dirname(root); const BUILD_TARGETS = [ @@ -678,7 +721,8 @@ BUILD_TARGETS.forEach(buildTarget => { const packageTasks: task.Task[] = [ compileNativeExtensionsBuildTask, util.rimraf(path.join(buildRoot, destinationFolderName)), - packageTask(platform, arch, sourceFolderName, destinationFolderName, opts) + packageTask(platform, arch, sourceFolderName, destinationFolderName, opts), + copyCopilotNativeDepsTask(platform, arch, destinationFolderName) ]; if (platform === 'win32') { @@ -692,7 +736,13 @@ BUILD_TARGETS.forEach(buildTarget => { if (useEsbuildTranspile) { const esbuildBundleTask = task.define( `esbuild-bundle${dashed(platform)}${dashed(arch)}${dashed(minified)}`, - () => runEsbuildBundle(sourceFolderName, !!minified, true, 'desktop', minified ? `${sourceMappingURLBase}/core` : undefined) + () => runEsbuildBundle( + sourceFolderName, + !!minified, + true, + 'desktop', + minified && useCdnSourceMapsForPackagingTasks ? `${sourceMappingURLBase}/core` : undefined + ) ); vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( copyCodiconsTask, diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index e9cc3720fcf7f..3e6b29adfe9fa 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -33,7 +33,7 @@ const quality = (product as { quality?: string }).quality; const version = (quality && quality !== 'stable') ? `${packageJson.version}-${quality}` : packageJson.version; // esbuild-based bundle for standalone web -function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promise { +function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, sourceMapBaseUrl?: string): Promise { return new Promise((resolve, reject) => { const scriptPath = path.join(REPO_ROOT, 'build/next/index.ts'); const args = [scriptPath, 'bundle', '--out', outDir, '--target', 'web']; @@ -44,6 +44,9 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promis if (nls) { args.push('--nls'); } + if (sourceMapBaseUrl) { + args.push('--source-map-base-url', sourceMapBaseUrl); + } const proc = cp.spawn(process.execPath, args, { cwd: REPO_ROOT, @@ -164,8 +167,9 @@ const minifyVSCodeWebTask = task.define('minify-vscode-web-OLD', task.series( gulp.task(minifyVSCodeWebTask); // esbuild-based tasks (new) +const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', () => runEsbuildBundle('out-vscode-web', false, true)); -const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true)); +const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`)); function packageTask(sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index d04e7f1f0e7d3..c393e8247f18a 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -14,6 +14,7 @@ import product from '../product.json' with { type: 'json' }; import { getVersion } from './lib/getVersion.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; +import type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -112,6 +113,18 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { Quality: quality }; + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration + ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded + : undefined; + + if (embedded) { + definitions['ProxyExeBasename'] = embedded.nameShort; + definitions['ProxyAppUserId'] = embedded.win32AppUserModelId; + definitions['ProxyNameLong'] = embedded.nameLong; + definitions['ProxyExeUrlProtocol'] = embedded.urlProtocol; + } + if (quality === 'stable' || quality === 'insider') { definitions['AppxPackage'] = `${quality === 'stable' ? 'code' : 'code_insider'}_${arch}.appx`; definitions['AppxPackageDll'] = `${quality === 'stable' ? 'code' : 'code_insider'}_explorer_command_${arch}.dll`; diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts new file mode 100644 index 0000000000000..f182c9829a9fc --- /dev/null +++ b/build/lib/copilot.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The platforms that @github/copilot ships platform-specific packages for. + * These are the `@github/copilot-{platform}` optional dependency packages. + */ +export const copilotPlatforms = [ + 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', + 'win32-arm64', 'win32-x64', +]; + +/** + * Converts VS Code build platform/arch to the values that Node.js reports + * at runtime via `process.platform` and `process.arch`. + * + * The copilot SDK's `loadNativeModule` looks up native binaries under + * `prebuilds/${process.platform}-${process.arch}/`, so the directory names + * must match these runtime values exactly. + */ +function toNodePlatformArch(platform: string, arch: string): { nodePlatform: string; nodeArch: string } { + // alpine is musl-linux; Node still reports process.platform === 'linux' + let nodePlatform = platform === 'alpine' ? 'linux' : platform; + let nodeArch = arch; + + if (arch === 'armhf') { + // VS Code build uses 'armhf'; Node reports process.arch === 'arm' + nodeArch = 'arm'; + } else if (arch === 'alpine') { + // Legacy: { platform: 'linux', arch: 'alpine' } means alpine-x64 + nodePlatform = 'linux'; + nodeArch = 'x64'; + } + + return { nodePlatform, nodeArch }; +} + +/** + * Returns a glob filter that strips @github/copilot platform packages + * for architectures other than the build target. + * + * For platforms the copilot SDK doesn't natively support (e.g. alpine, armhf), + * ALL platform packages are stripped - that's fine because the SDK doesn't ship + * binaries for those platforms anyway, and we replace them with VS Code's own. + */ +export function getCopilotExcludeFilter(platform: string, arch: string): string[] { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const targetPlatformArch = `${nodePlatform}-${nodeArch}`; + const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); + + // Strip wrong-architecture @github/copilot-{platform} packages. + // All copilot prebuilds are stripped by .moduleignore; VS Code's own + // node-pty is copied into the prebuilds location by a post-packaging task. + const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); + + return ['**', ...excludes]; +} + +/** + * Copies VS Code's own node-pty binaries into the copilot SDK's + * expected locations so the copilot CLI subprocess can find them at runtime. + * The copilot-bundled prebuilds are stripped by .moduleignore; + * this replaces them with the same binaries VS Code already ships, avoiding + * new system dependency requirements. + * + * This works even for platforms the copilot SDK doesn't natively support + * (e.g. alpine, armhf) because the SDK's native module loader simply + * looks for `prebuilds/{process.platform}-{process.arch}/pty.node` - it + * doesn't validate the platform against a supported list. + * + * Failures are logged but do not throw, to avoid breaking the build on + * platforms where something unexpected happens. + * + * @param nodeModulesDir Absolute path to the node_modules directory that + * contains both the source binaries (node-pty) and the copilot SDK + * target directories. + */ +export function copyCopilotNativeDeps(platform: string, arch: string, nodeModulesDir: string): void { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const platformArch = `${nodePlatform}-${nodeArch}`; + + const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); + if (!fs.existsSync(copilotBase)) { + console.warn(`[copyCopilotNativeDeps] @github/copilot not found at ${copilotBase}, skipping`); + return; + } + + const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); + if (!fs.existsSync(nodePtySource)) { + console.warn(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}, skipping`); + return; + } + + try { + // Copy node-pty (pty.node + spawn-helper on Unix, conpty.node + conpty/ on Windows) + // into copilot prebuilds so the SDK finds them via loadNativeModule. + const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); + fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); + fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); + console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + } catch (err) { + console.warn(`[copyCopilotNativeDeps] Failed to copy node-pty for ${platformArch}: ${err}`); + } +} diff --git a/build/lib/date.ts b/build/lib/date.ts index 68d52521349ed..99ba91a5282df 100644 --- a/build/lib/date.ts +++ b/build/lib/date.ts @@ -5,9 +5,23 @@ import path from 'path'; import fs from 'fs'; +import { execSync } from 'child_process'; const root = path.join(import.meta.dirname, '..', '..'); +/** + * Get the ISO date for the build. Uses the git commit date of HEAD + * so that independent builds on different machines produce the same + * timestamp (required for deterministic builds, e.g. macOS Universal). + */ +export function getGitCommitDate(): string { + try { + return execSync('git log -1 --format=%cI HEAD', { cwd: root, encoding: 'utf8' }).trim(); + } catch { + return new Date().toISOString(); + } +} + /** * Writes a `outDir/date` file with the contents of the build * so that other tasks during the build process can use it and @@ -18,7 +32,7 @@ export function writeISODate(outDir: string) { const outDirectory = path.join(root, outDir); fs.mkdirSync(outDirectory, { recursive: true }); - const date = new Date().toISOString(); + const date = getGitCommitDate(); fs.writeFileSync(path.join(outDirectory, 'date'), date, 'utf8'); resolve(); diff --git a/build/lib/embeddedType.ts b/build/lib/embeddedType.ts new file mode 100644 index 0000000000000..4b3075f4a7165 --- /dev/null +++ b/build/lib/embeddedType.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type EmbeddedProductInfo = { + nameShort: string; + nameLong: string; + applicationName: string; + dataFolderName: string; + darwinBundleIdentifier: string; + urlProtocol: string; + win32AppUserModelId: string; + win32MutexName: string; + win32RegValueName: string; + win32NameVersion: string; + win32VersionedUpdate: boolean; +}; diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 5710f4d6919fd..c5a74b53e046f 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -20,10 +20,8 @@ import fancyLog from 'fancy-log'; import ansiColors from 'ansi-colors'; import buffer from 'gulp-buffer'; import * as jsoncParser from 'jsonc-parser'; -import webpack from 'webpack'; import { getProductionDependencies } from './dependencies.ts'; import { type IExtensionDefinition, getExtensionStream } from './builtInExtensions.ts'; -import { getVersion } from './getVersion.ts'; import { fetchUrls, fetchGithub } from './fetch.ts'; import { createTsgoStream, spawnTsgo } from './tsgo.ts'; import vzip from 'gulp-vinyl-zip'; @@ -32,8 +30,8 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); const root = path.dirname(path.dirname(import.meta.dirname)); -const commit = getVersion(root); -const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; +// const commit = getVersion(root); +// const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; function minifyExtensionResources(input: Stream): Stream { const jsonFilter = filter(['**/*.json', '**/*.code-snippets'], { restore: true }); @@ -65,32 +63,24 @@ function updateExtensionPackageJSON(input: Stream, update: (data: any) => any): .pipe(packageJsonFilter.restore); } -function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolean): Stream { +function fromLocal(extensionPath: string, forWeb: boolean, _disableMangle: boolean): Stream { const esbuildConfigFileName = forWeb ? 'esbuild.browser.mts' : 'esbuild.mts'; - const webpackConfigFileName = forWeb - ? `extension-browser.webpack.config.js` - : `extension.webpack.config.js`; - const hasEsbuild = fs.existsSync(path.join(extensionPath, esbuildConfigFileName)); - const hasWebpack = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); let input: Stream; let isBundled = false; if (hasEsbuild) { - // Unlike webpack, esbuild only does bundling so we still want to run a separate type check step + // Esbuild only does bundling so we still want to run a separate type check step input = es.merge( fromLocalEsbuild(extensionPath, esbuildConfigFileName), ...getBuildRootsForExtension(extensionPath).map(root => typeCheckExtensionStream(root, forWeb)), ); isBundled = true; - } else if (hasWebpack) { - input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); - isBundled = true; } else { input = fromLocalNormal(extensionPath); } @@ -122,132 +112,6 @@ export function typeCheckExtensionStream(extensionPath: string, forWeb: boolean) return createTsgoStream(tsconfigPath, { taskName: 'typechecking extension (tsgo)', noEmit: true }); } -function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, disableMangle: boolean): Stream { - const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); - const webpack = require('webpack'); - const webpackGulp = require('webpack-stream'); - const result = es.through(); - - const packagedDependencies: string[] = []; - const stripOutSourceMaps: string[] = []; - const packageJsonConfig = require(path.join(extensionPath, 'package.json')); - if (packageJsonConfig.dependencies) { - const webpackConfig = require(path.join(extensionPath, webpackConfigFileName)); - const webpackRootConfig = webpackConfig.default; - for (const key in webpackRootConfig.externals) { - if (key in packageJsonConfig.dependencies) { - packagedDependencies.push(key); - } - } - - if (webpackConfig.StripOutSourceMaps) { - for (const filePath of webpackConfig.StripOutSourceMaps) { - stripOutSourceMaps.push(filePath); - } - } - } - - // TODO: add prune support based on packagedDependencies to vsce.PackageManager.Npm similar - // to vsce.PackageManager.Yarn. - // A static analysis showed there are no webpack externals that are dependencies of the current - // local extensions so we can use the vsce.PackageManager.None config to ignore dependencies list - // as a temporary workaround. - vsce.listFiles({ cwd: extensionPath, packageManager: vsce.PackageManager.None, packagedDependencies }).then(fileNames => { - const files = fileNames - .map(fileName => path.join(extensionPath, fileName)) - .map(filePath => new File({ - path: filePath, - stat: fs.statSync(filePath), - base: extensionPath, - contents: fs.createReadStream(filePath) - })); - - // check for a webpack configuration files, then invoke webpack - // and merge its output with the files stream. - const webpackConfigLocations = (glob.sync( - path.join(extensionPath, '**', webpackConfigFileName), - { ignore: ['**/node_modules'] } - ) as string[]); - const webpackStreams = webpackConfigLocations.flatMap(webpackConfigPath => { - - const webpackDone = (err: Error | undefined, stats: any) => { - fancyLog(`Bundled extension: ${ansiColors.yellow(path.join(path.basename(extensionPath), path.relative(extensionPath, webpackConfigPath)))}...`); - if (err) { - result.emit('error', err); - } - const { compilation } = stats; - if (compilation.errors.length > 0) { - result.emit('error', compilation.errors.join('\n')); - } - if (compilation.warnings.length > 0) { - result.emit('error', compilation.warnings.join('\n')); - } - }; - - const exportedConfig = require(webpackConfigPath).default; - return (Array.isArray(exportedConfig) ? exportedConfig : [exportedConfig]).map(config => { - const webpackConfig = { - ...config, - ...{ mode: 'production' } - }; - if (disableMangle) { - if (Array.isArray(config.module.rules)) { - for (const rule of config.module.rules) { - if (Array.isArray(rule.use)) { - for (const use of rule.use) { - if (String(use.loader).endsWith('mangle-loader.js')) { - use.options.disabled = true; - } - } - } - } - } - } - const relativeOutputPath = path.relative(extensionPath, webpackConfig.output.path); - - return webpackGulp(webpackConfig, webpack, webpackDone) - .pipe(es.through(function (data) { - data.stat = data.stat || {}; - data.base = extensionPath; - this.emit('data', data); - })) - .pipe(es.through(function (data: File) { - // source map handling: - // * rewrite sourceMappingURL - // * save to disk so that upload-task picks this up - if (path.extname(data.basename) === '.js') { - if (stripOutSourceMaps.indexOf(data.relative) >= 0) { // remove source map - const contents = (data.contents as Buffer).toString('utf8'); - data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, ''), 'utf8'); - } else { - const contents = (data.contents as Buffer).toString('utf8'); - data.contents = Buffer.from(contents.replace(/\n\/\/# sourceMappingURL=(.*)$/gm, function (_m, g1) { - return `\n//# sourceMappingURL=${sourceMappingURLBase}/extensions/${path.basename(extensionPath)}/${relativeOutputPath}/${g1}`; - }), 'utf8'); - } - } - - this.emit('data', data); - })); - }); - }); - - es.merge(...webpackStreams, es.readArray(files)) - // .pipe(es.through(function (data) { - // // debug - // console.log('out', data.path, data.contents.length); - // this.emit('data', data); - // })) - .pipe(result); - - }).catch(err => { - console.error(extensionPath); - console.error(packagedDependencies); - result.emit('error', err); - }); - - return result.pipe(createStatsStream(path.basename(extensionPath))); -} function fromLocalNormal(extensionPath: string): Stream { const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); @@ -274,6 +138,14 @@ function fromLocalNormal(extensionPath: string): Stream { function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): Stream { const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); const result = es.through(); + const extensionName = path.basename(extensionPath); + + // Extensions built with esbuild can still externalize runtime dependencies. + // Ensure those externals are included in the packaged built-in extension. + const packagedDependenciesByExtension: Record = { + 'git': ['@vscode/fs-copyfile'] + }; + const packagedDependencies = packagedDependenciesByExtension[extensionName] ?? []; const esbuildScript = path.join(extensionPath, esbuildConfigFileName); @@ -299,6 +171,25 @@ function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): // After esbuild completes, collect all files using vsce return vsce.listFiles({ cwd: extensionPath, packageManager: vsce.PackageManager.None }); }).then(fileNames => { + if (packagedDependencies.length > 0) { + const packagedDependencyFileNames = packagedDependencies.flatMap(dependency => + glob.sync(path.join(extensionPath, 'node_modules', dependency, '**'), { nodir: true, dot: true }) + .map(filePath => path.relative(extensionPath, filePath)) + .filter(filePath => { + // Exclude non-.node files from build directories to avoid timestamp-sensitive + // artifacts (e.g. Makefile) that break macOS universal builds due to SHA mismatches. + const parts = filePath.split(path.sep); + const buildIndex = parts.indexOf('build'); + if (buildIndex !== -1) { + return filePath.endsWith('.node'); + } + return true; + }) + ); + + fileNames = Array.from(new Set([...fileNames, ...packagedDependencyFileNames])); + } + const files = fileNames .map(fileName => path.join(extensionPath, fileName)) .map(filePath => new File({ @@ -311,6 +202,7 @@ function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): es.readArray(files).pipe(result); }).catch(err => { console.error(extensionPath); + console.error(packagedDependencies); result.emit('error', err); }); @@ -405,6 +297,7 @@ export function fromGithub({ name, version, repo, sha256, metadata }: IExtension * platform that is being built. */ const nativeExtensions = [ + 'git', 'microsoft-authentication', ]; @@ -649,70 +542,6 @@ export function translatePackageJSON(packageJSON: string, packageNLSPath: string const extensionsPath = path.join(root, 'extensions'); -export async function webpackExtensions(taskName: string, isWatch: boolean, webpackConfigLocations: { configPath: string; outputRoot?: string }[]) { - const webpack = require('webpack') as typeof import('webpack'); - - const webpackConfigs: webpack.Configuration[] = []; - - for (const { configPath, outputRoot } of webpackConfigLocations) { - const configOrFnOrArray = require(configPath).default; - function addConfig(configOrFnOrArray: webpack.Configuration | ((env: unknown, args: unknown) => webpack.Configuration) | webpack.Configuration[]) { - for (const configOrFn of Array.isArray(configOrFnOrArray) ? configOrFnOrArray : [configOrFnOrArray]) { - const config = typeof configOrFn === 'function' ? configOrFn({}, {}) : configOrFn; - if (outputRoot) { - config.output!.path = path.join(outputRoot, path.relative(path.dirname(configPath), config.output!.path!)); - } - webpackConfigs.push(config); - } - } - addConfig(configOrFnOrArray); - } - - function reporter(fullStats: any) { - if (Array.isArray(fullStats.children)) { - for (const stats of fullStats.children) { - const outputPath = stats.outputPath; - if (outputPath) { - const relativePath = path.relative(extensionsPath, outputPath).replace(/\\/g, '/'); - const match = relativePath.match(/[^\/]+(\/server|\/client)?/); - fancyLog(`Finished ${ansiColors.green(taskName)} ${ansiColors.cyan(match![0])} with ${stats.errors.length} errors.`); - } - if (Array.isArray(stats.errors)) { - stats.errors.forEach((error: any) => { - fancyLog.error(error); - }); - } - if (Array.isArray(stats.warnings)) { - stats.warnings.forEach((warning: any) => { - fancyLog.warn(warning); - }); - } - } - } - } - return new Promise((resolve, reject) => { - if (isWatch) { - webpack(webpackConfigs).watch({}, (err, stats) => { - if (err) { - reject(); - } else { - reporter(stats?.toJson()); - } - }); - } else { - webpack(webpackConfigs).run((err, stats) => { - if (err) { - fancyLog.error(err); - reject(); - } else { - reporter(stats?.toJson()); - resolve(); - } - }); - } - }); -} - export async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]): Promise { function reporter(stdError: string, script: string) { const matches = (stdError || '').match(/\> (.+): error: (.+)?/g); diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 921137824ee3c..d2b50232093ef 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -170,6 +170,10 @@ "name": "vs/workbench/contrib/inlineChat", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/imageCarousel", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/chat", "project": "vscode-workbench" @@ -561,6 +565,132 @@ { "name": "vs/workbench/contrib/list", "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/browserView", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/dropOrPasteInto", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/editTelemetry", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/inlineCompletions", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/mcp", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/meteredConnection", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/processExplorer", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/remoteCodingAgents", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/telemetry", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/contrib/welcomeAgentSessions", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/services/accounts", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/services/chat", + "project": "vscode-workbench" + }, + { + "name": "vs/workbench/services/request", + "project": "vscode-workbench" + } + ], + "sessions": [ + { + "name": "vs/sessions", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/accountMenu", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/agentFeedback", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/aiCustomizationTreeView", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/applyCommitsToParentRepo", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/changes", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/chat", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/copilotChatSessions", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/codeReview", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/fileTreeView", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/files", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/git", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/logs", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/remoteAgentHost", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/sessions", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/terminal", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/welcome", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/chatDebug", + "project": "vscode-sessions" } ] } diff --git a/build/lib/i18n.ts b/build/lib/i18n.ts index 8ebcb1f177b06..61ed524f35bf8 100644 --- a/build/lib/i18n.ts +++ b/build/lib/i18n.ts @@ -391,7 +391,8 @@ const editorProject: string = 'vscode-editor', workbenchProject: string = 'vscode-workbench', extensionsProject: string = 'vscode-extensions', setupProject: string = 'vscode-setup', - serverProject: string = 'vscode-server'; + serverProject: string = 'vscode-server', + sessionsProject: string = 'vscode-sessions'; export function getResource(sourceFile: string): Resource { let resource: string; @@ -416,6 +417,11 @@ export function getResource(sourceFile: string): Resource { return { name: resource, project: workbenchProject }; } else if (/^vs\/workbench/.test(sourceFile)) { return { name: 'vs/workbench', project: workbenchProject }; + } else if (/^vs\/sessions\/contrib/.test(sourceFile)) { + resource = sourceFile.split('/', 4).join('/'); + return { name: resource, project: sessionsProject }; + } else if (/^vs\/sessions/.test(sourceFile)) { + return { name: 'vs/sessions', project: sessionsProject }; } throw new Error(`Could not identify the XLF bundle for ${sourceFile}`); @@ -737,6 +743,10 @@ export function prepareI18nPackFiles(resultingTranslationPaths: TranslationPath[ if (EXTERNAL_EXTENSIONS.find(e => e === resource)) { project = extensionsProject; } + // vscode-setup has its own import path via prepareIslFiles + if (project === setupProject) { + return; + } const contents = xlf.contents!.toString(); log(`Found ${project}: ${resource}`); const parsePromise = getL10nFilesFromXlf(contents); diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9c1e1e0e87a8f..f58dda70afad4 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -39,28 +39,28 @@ ], "policies": [ { - "key": "chat.mcp.gallery.serviceUrl", - "name": "McpGalleryServiceUrl", - "category": "InteractiveSession", - "minimumVersion": "1.101", + "key": "extensions.gallery.serviceUrl", + "name": "ExtensionGalleryServiceUrl", + "category": "Extensions", + "minimumVersion": "1.99", "localization": { "description": { - "key": "mcp.gallery.serviceUrl", - "value": "Configure the MCP Gallery service URL to connect to" + "key": "extensions.gallery.serviceUrl", + "value": "Configure the Marketplace service URL to connect to" } }, "type": "string", "default": "" }, { - "key": "extensions.gallery.serviceUrl", - "name": "ExtensionGalleryServiceUrl", - "category": "Extensions", - "minimumVersion": "1.99", + "key": "chat.mcp.gallery.serviceUrl", + "name": "McpGalleryServiceUrl", + "category": "InteractiveSession", + "minimumVersion": "1.101", "localization": { "description": { - "key": "extensions.gallery.serviceUrl", - "value": "Configure the Marketplace service URL to connect to" + "key": "mcp.gallery.serviceUrl", + "value": "Configure the MCP Gallery service URL to connect to" } }, "type": "string", @@ -169,6 +169,20 @@ "type": "boolean", "default": true }, + { + "key": "chat.editMode.hidden", + "name": "DeprecatedEditModeHidden", + "category": "InteractiveSession", + "minimumVersion": "1.112", + "localization": { + "description": { + "key": "chat.editMode.hidden", + "value": "When enabled, hides the Edit mode from the chat mode picker." + } + }, + "type": "boolean", + "default": true + }, { "key": "chat.useHooks", "name": "ChatHooks", @@ -286,6 +300,20 @@ }, "type": "boolean", "default": true + }, + { + "key": "workbench.browser.enableChatTools", + "name": "BrowserChatTools", + "category": "InteractiveSession", + "minimumVersion": "1.110", + "localization": { + "description": { + "key": "browser.enableChatTools", + "value": "When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser." + } + }, + "type": "boolean", + "default": false } ] } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a913a9534fcfc..f0b99e4e49300 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -21,6 +21,7 @@ "--vscode-activityErrorBadge-foreground", "--vscode-activityWarningBadge-background", "--vscode-activityWarningBadge-foreground", + "--vscode-agentFeedbackInputWidget-border", "--vscode-agentSessionReadIndicator-foreground", "--vscode-agentSessionSelectedBadge-border", "--vscode-agentSessionSelectedUnfocusedBadge-border", @@ -35,6 +36,7 @@ "--vscode-breadcrumb-focusForeground", "--vscode-breadcrumb-foreground", "--vscode-breadcrumbPicker-background", + "--vscode-browser-border", "--vscode-button-background", "--vscode-button-border", "--vscode-button-foreground", @@ -644,6 +646,14 @@ "--vscode-searchEditor-findMatchBorder", "--vscode-searchEditor-textInputBorder", "--vscode-selection-background", + "--vscode-sessionsAuxiliaryBar-background", + "--vscode-sessionsChatBar-background", + "--vscode-sessionsPanel-background", + "--vscode-sessionsSidebar-background", + "--vscode-sessionsSidebarHeader-background", + "--vscode-sessionsSidebarHeader-foreground", + "--vscode-sessionsUpdateButton-downloadedBackground", + "--vscode-sessionsUpdateButton-downloadingBackground", "--vscode-settings-checkboxBackground", "--vscode-settings-checkboxBorder", "--vscode-settings-checkboxForeground", @@ -945,6 +955,7 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--update-progress", "--reveal-button-size", "--part-background", "--part-border-color", @@ -968,6 +979,14 @@ "--vscode-repl-line-height", "--vscode-sash-hover-size", "--vscode-sash-size", + "--vscode-shadow-active-tab", + "--vscode-shadow-depth-x", + "--vscode-shadow-depth-y", + "--vscode-shadow-hover", + "--vscode-shadow-lg", + "--vscode-shadow-md", + "--vscode-shadow-sm", + "--vscode-shadow-xl", "--vscode-testing-coverage-lineHeight", "--vscode-editorStickyScroll-scrollableWidth", "--vscode-editorStickyScroll-foldingOpacityTransition", @@ -998,6 +1017,7 @@ "--text-link-decoration", "--vscode-action-item-auto-timeout", "--monaco-editor-warning-decoration", + "--animation-angle", "--animation-opacity", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", diff --git a/build/lib/test/i18n.test.ts b/build/lib/test/i18n.test.ts index 7d5bb0433feed..6c9409bcb4a34 100644 --- a/build/lib/test/i18n.test.ts +++ b/build/lib/test/i18n.test.ts @@ -31,7 +31,8 @@ suite('XLF Parser Tests', () => { test('JSON file source path to Transifex resource match', () => { const editorProject: string = 'vscode-editor', - workbenchProject: string = 'vscode-workbench'; + workbenchProject: string = 'vscode-workbench', + sessionsProject: string = 'vscode-sessions'; const platform: i18n.Resource = { name: 'vs/platform', project: editorProject }, editorContrib = { name: 'vs/editor/contrib', project: editorProject }, @@ -40,7 +41,9 @@ suite('XLF Parser Tests', () => { code = { name: 'vs/code', project: workbenchProject }, workbenchParts = { name: 'vs/workbench/contrib/html', project: workbenchProject }, workbenchServices = { name: 'vs/workbench/services/textfile', project: workbenchProject }, - workbench = { name: 'vs/workbench', project: workbenchProject }; + workbench = { name: 'vs/workbench', project: workbenchProject }, + sessionsContrib = { name: 'vs/sessions/contrib/chat', project: sessionsProject }, + sessions = { name: 'vs/sessions', project: sessionsProject }; assert.deepStrictEqual(i18n.getResource('vs/platform/actions/browser/menusExtensionPoint'), platform); assert.deepStrictEqual(i18n.getResource('vs/editor/contrib/clipboard/browser/clipboard'), editorContrib); @@ -50,5 +53,7 @@ suite('XLF Parser Tests', () => { assert.deepStrictEqual(i18n.getResource('vs/workbench/contrib/html/browser/webview'), workbenchParts); assert.deepStrictEqual(i18n.getResource('vs/workbench/services/textfile/node/testFileService'), workbenchServices); assert.deepStrictEqual(i18n.getResource('vs/workbench/browser/parts/panel/panelActions'), workbench); + assert.deepStrictEqual(i18n.getResource('vs/sessions/contrib/chat/browser/chatWidget'), sessionsContrib); + assert.deepStrictEqual(i18n.getResource('vs/sessions/browser/layoutActions'), sessions); }); }); diff --git a/build/lib/util.ts b/build/lib/util.ts index e4d01e143c93b..4203e6e653041 100644 --- a/build/lib/util.ts +++ b/build/lib/util.ts @@ -381,6 +381,12 @@ export function getElectronVersion(): Record { return { electronVersion, msBuildId }; } +export function getVersionedResourcesFolder(platform: string, commit: string): string { + const productJson = JSON.parse(fs.readFileSync(path.join(root, 'product.json'), 'utf8')); + const useVersionedUpdate = platform === 'win32' && productJson.win32VersionedUpdate; + return useVersionedUpdate ? commit.substring(0, 10) : ''; +} + export class VinylStat implements fs.Stats { readonly dev: number; diff --git a/build/next/index.ts b/build/next/index.ts index b0120837efa26..565bafc72ecea 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -7,11 +7,13 @@ import * as esbuild from 'esbuild'; import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; + import glob from 'glob'; import gulpWatch from '../lib/watch/index.ts'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts'; import { convertPrivateFields, adjustSourceMap, type ConvertPrivateFieldsResult } from './private-to-property.ts'; import { getVersion } from '../lib/getVersion.ts'; +import { getGitCommitDate } from '../lib/date.ts'; import product from '../../product.json' with { type: 'json' }; import packageJson from '../../package.json' with { type: 'json' }; import { useEsbuildTranspile } from '../buildConfig.ts'; @@ -72,7 +74,8 @@ const extensionHostEntryPoints = [ ]; function isExtensionHostBundle(filePath: string): boolean { - return extensionHostEntryPoints.some(ep => filePath.endsWith(`${ep}.js`)); + const normalized = filePath.replaceAll('\\', '/'); + return extensionHostEntryPoints.some(ep => normalized.endsWith(`${ep}.js`)); } // Workers - shared between targets @@ -98,6 +101,7 @@ const desktopEntryPoints = [ 'vs/workbench/contrib/debug/node/telemetryApp', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', 'vs/workbench/api/node/extensionHostProcess', ]; @@ -125,6 +129,7 @@ const serverEntryPoints = [ 'vs/workbench/api/node/extensionHostProcess', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', ]; // Bootstrap files per target @@ -257,6 +262,12 @@ const desktopResourcePatterns = [ 'vs/workbench/contrib/terminal/common/scripts/*.psm1', 'vs/workbench/contrib/terminal/common/scripts/*.fish', 'vs/workbench/contrib/terminal/common/scripts/*.zsh', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.psd1', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.psm1', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.dll', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.ps1xml', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/net6plus/*.dll', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/netstd/*.dll', 'vs/workbench/contrib/externalTerminal/**/*.scpt', // Media - audio @@ -270,6 +281,10 @@ const desktopResourcePatterns = [ 'vs/workbench/services/extensionManagement/common/media/*.png', 'vs/workbench/browser/parts/editor/media/*.png', 'vs/workbench/contrib/debug/browser/media/*.png', + + // Sessions - built-in prompts and skills + 'vs/sessions/prompts/*.prompt.md', + 'vs/sessions/skills/**/SKILL.md', ]; // Resources for server target (minimal - no UI) @@ -291,6 +306,12 @@ const serverResourcePatterns = [ 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh', 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-login.zsh', 'vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.psd1', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.psm1', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.dll', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/*.ps1xml', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/net6plus/*.dll', + 'vs/workbench/contrib/terminal/common/scripts/psreadline/netstd/*.dll', ]; // Resources for server-web target (server + web UI) @@ -419,13 +440,13 @@ function scanBuiltinExtensions(extensionsRoot: string): Array { async function bundle(outDir: string, doMinify: boolean, doNls: boolean, doManglePrivates: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { await cleanDir(outDir); - // Write build date file (used by packaging to embed in product.json) + // Write build date file (used by packaging to embed in product.json). + // Reuse the date from out-build/date if it exists (written by the gulp + // writeISODate task) so that all parallel bundle outputs share the same + // timestamp - this is required for deterministic builds (e.g. macOS Universal). const outDirPath = path.join(REPO_ROOT, outDir); await fs.promises.mkdir(outDirPath, { recursive: true }); - await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); + let buildDate: string; + try { + buildDate = await fs.promises.readFile(path.join(REPO_ROOT, 'out-build', 'date'), 'utf8'); + } catch { + buildDate = getGitCommitDate(); + } + await fs.promises.writeFile(path.join(outDirPath, 'date'), buildDate, 'utf8'); console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}${doManglePrivates ? ' (mangle-privates)' : ''}`); const t1 = Date.now(); @@ -885,6 +915,13 @@ ${tslib}`, const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = []; // Map from JS file path to pre-mangle content + edits, for source map adjustment const mangleEdits = new Map(); + // Map from JS file path to pre-NLS content + edits, for source map adjustment + const nlsEdits = new Map(); + // Defer .map files until all .js files are processed, because esbuild may + // emit the .map file in a different build result than the .js file (e.g. + // code-split chunks), and we need the NLS/mangle edits from the .js pass + // to be available when adjusting the .map. + const deferredMaps: { path: string; text: string; contents: Uint8Array }[] = []; for (const { result } of buildResults) { if (!result.outputFiles) { continue; @@ -913,7 +950,12 @@ ${tslib}`, // Apply NLS post-processing if enabled (JS only) if (file.path.endsWith('.js') && doNls && indexMap.size > 0) { - content = postProcessNLS(content, indexMap, preserveEnglish); + const preNLSCode = content; + const nlsResult = postProcessNLS(content, indexMap, preserveEnglish); + content = nlsResult.code; + if (nlsResult.edits.length > 0) { + nlsEdits.set(file.path, { preNLSCode, edits: nlsResult.edits }); + } } // Rewrite sourceMappingURL to CDN URL if configured @@ -931,16 +973,8 @@ ${tslib}`, await fs.promises.writeFile(file.path, content); } else if (file.path.endsWith('.map')) { - // Source maps may need adjustment if private fields were mangled - const jsPath = file.path.replace(/\.map$/, ''); - const editInfo = mangleEdits.get(jsPath); - if (editInfo) { - const mapJson = JSON.parse(file.text); - const adjusted = adjustSourceMap(mapJson, editInfo.preMangleCode, editInfo.edits); - await fs.promises.writeFile(file.path, JSON.stringify(adjusted)); - } else { - await fs.promises.writeFile(file.path, file.contents); - } + // Defer .map processing until all .js files have been handled + deferredMaps.push({ path: file.path, text: file.text, contents: file.contents }); } else { // Write other files (assets, etc.) as-is await fs.promises.writeFile(file.path, file.contents); @@ -949,6 +983,27 @@ ${tslib}`, bundled++; } + // Second pass: process deferred .map files now that all mangle/NLS edits + // have been collected from .js processing above. + for (const mapFile of deferredMaps) { + const jsPath = mapFile.path.replace(/\.map$/, ''); + const mangle = mangleEdits.get(jsPath); + const nls = nlsEdits.get(jsPath); + + if (mangle || nls) { + let mapJson = JSON.parse(mapFile.text); + if (mangle) { + mapJson = adjustSourceMap(mapJson, mangle.preMangleCode, mangle.edits); + } + if (nls) { + mapJson = adjustSourceMap(mapJson, nls.preNLSCode, nls.edits); + } + await fs.promises.writeFile(mapFile.path, JSON.stringify(mapJson)); + } else { + await fs.promises.writeFile(mapFile.path, mapFile.contents); + } + } + // Log mangle-privates stats if (doManglePrivates && mangleStats.length > 0) { let totalClasses = 0, totalFields = 0, totalEdits = 0, totalElapsed = 0; @@ -1128,7 +1183,7 @@ async function main(): Promise { // Write build date file (used by packaging to embed in product.json) const outDirPath = path.join(REPO_ROOT, outDir); await fs.promises.mkdir(outDirPath, { recursive: true }); - await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); + await fs.promises.writeFile(path.join(outDirPath, 'date'), getGitCommitDate(), 'utf8'); console.log(`[transpile] ${SRC_DIR} → ${outDir}${options.excludeTests ? ' (excluding tests)' : ''}`); const t1 = Date.now(); diff --git a/build/next/nls-plugin.ts b/build/next/nls-plugin.ts index 7be3faccf2439..e2b19f7d7f13d 100644 --- a/build/next/nls-plugin.ts +++ b/build/next/nls-plugin.ts @@ -12,6 +12,7 @@ import { analyzeLocalizeCalls, parseLocalizeKeyOrValue } from '../lib/nls-analysis.ts'; +import type { TextEdit } from './private-to-property.ts'; // ============================================================================ // Types @@ -148,12 +149,13 @@ export async function finalizeNLS( /** * Post-processes a JavaScript file to replace NLS placeholders with indices. + * Returns the transformed code and the edits applied (for source map adjustment). */ export function postProcessNLS( content: string, indexMap: Map, preserveEnglish: boolean -): string { +): { code: string; edits: readonly TextEdit[] } { return replaceInOutput(content, indexMap, preserveEnglish); } @@ -244,7 +246,7 @@ function generateNLSSourceMap( const generator = new SourceMapGenerator(); generator.setSourceContent(filePath, originalSource); - const lineCount = originalSource.split('\n').length; + const lines = originalSource.split('\n'); // Group edits by line const editsByLine = new Map(); @@ -257,7 +259,7 @@ function generateNLSSourceMap( arr.push(edit); } - for (let line = 0; line < lineCount; line++) { + for (let line = 0; line < lines.length; line++) { const smLine = line + 1; // source maps use 1-based lines // Always map start of line @@ -273,7 +275,8 @@ function generateNLSSourceMap( let cumulativeShift = 0; - for (const edit of lineEdits) { + for (let i = 0; i < lineEdits.length; i++) { + const edit = lineEdits[i]; const origLen = edit.endCol - edit.startCol; // Map start of edit: the replacement begins at the same original position @@ -285,12 +288,20 @@ function generateNLSSourceMap( cumulativeShift += edit.newLength - origLen; - // Map content after edit: columns resume with the shift applied - generator.addMapping({ - generated: { line: smLine, column: edit.endCol + cumulativeShift }, - original: { line: smLine, column: edit.endCol }, - source: filePath, - }); + // Source maps don't interpolate columns — each query resolves to the + // last segment with generatedColumn <= queryColumn. A single mapping + // at edit-end would cause every subsequent column on this line to + // collapse to that one original position. Add per-column identity + // mappings from edit-end to the next edit (or end of line) so that + // esbuild's source-map composition preserves fine-grained accuracy. + const nextBound = i + 1 < lineEdits.length ? lineEdits[i + 1].startCol : lines[line].length; + for (let origCol = edit.endCol; origCol < nextBound; origCol++) { + generator.addMapping({ + generated: { line: smLine, column: origCol + cumulativeShift }, + original: { line: smLine, column: origCol }, + source: filePath, + }); + } } } } @@ -302,17 +313,19 @@ function replaceInOutput( content: string, indexMap: Map, preserveEnglish: boolean -): string { - // Replace all placeholders in a single pass using regex - // Two types of placeholders: - // - %%NLS:moduleId#key%% for localize() - message replaced with null - // - %%NLS2:moduleId#key%% for localize2() - message preserved - // Note: esbuild may use single or double quotes, so we handle both +): { code: string; edits: readonly TextEdit[] } { + // Collect all matches first, then apply from back to front so that byte + // offsets remain valid. Each match becomes a TextEdit in terms of the + // ORIGINAL content offsets, which is what adjustSourceMap expects. + + interface PendingEdit { start: number; end: number; replacement: string } + const pending: PendingEdit[] = []; if (preserveEnglish) { - // Just replace the placeholder with the index (both NLS and NLS2) - return content.replace(/["']%%NLS2?:([^%]+)%%["']/g, (match, inner) => { - // Try NLS first, then NLS2 + const re = /["']%%NLS2?:([^%]+)%%["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const inner = m[1]; let placeholder = `%%NLS:${inner}%%`; let index = indexMap.get(placeholder); if (index === undefined) { @@ -320,45 +333,60 @@ function replaceInOutput( index = indexMap.get(placeholder); } if (index !== undefined) { - return String(index); + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - // Placeholder not found in map, leave as-is (shouldn't happen) - return match; - }); + } } else { - // For NLS (localize): replace placeholder with index AND replace message with null - // For NLS2 (localize2): replace placeholder with index, keep message - // Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\ - // Note: esbuild may use single or double quotes, so we handle both - - // First handle NLS (localize) - replace both key and message - content = content.replace( - /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, - (match, inner, comma) => { - const placeholder = `%%NLS:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return `${index}${comma}null`; - } - return match; + // NLS (localize): replace placeholder with index AND replace message with null + const reNLS = /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g; + let m: RegExpExecArray | null; + while ((m = reNLS.exec(content)) !== null) { + const inner = m[1]; + const comma = m[2]; + const placeholder = `%%NLS:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: `${index}${comma}null` }); } - ); - - // Then handle NLS2 (localize2) - replace only key, keep message - content = content.replace( - /["']%%NLS2:([^%]+)%%["']/g, - (match, inner) => { - const placeholder = `%%NLS2:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return String(index); - } - return match; + } + + // NLS2 (localize2): replace only key, keep message + const reNLS2 = /["']%%NLS2:([^%]+)%%["']/g; + while ((m = reNLS2.exec(content)) !== null) { + const inner = m[1]; + const placeholder = `%%NLS2:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - ); + } + } - return content; + if (pending.length === 0) { + return { code: content, edits: [] }; } + + // Sort by offset ascending, then apply back-to-front to keep offsets valid + pending.sort((a, b) => a.start - b.start); + + // Build TextEdit[] (in original-content coordinates) and apply edits + const edits: TextEdit[] = []; + for (const p of pending) { + edits.push({ start: p.start, end: p.end, newText: p.replacement }); + } + + // Apply edits using forward-scanning parts array — O(N+K) instead of + // O(N*K) from repeated substring concatenation on large strings. + const parts: string[] = []; + let lastEnd = 0; + for (const p of pending) { + parts.push(content.substring(lastEnd, p.start)); + parts.push(p.replacement); + lastEnd = p.end; + } + parts.push(content.substring(lastEnd)); + + return { code: parts.join(''), edits }; } // ============================================================================ @@ -399,7 +427,11 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { // back to the original. Embed it inline so esbuild composes it // with its own bundle source map, making the final map point to // the original TS source. - const sourceName = relativePath.replace(/\\/g, '/'); + // This inline source map is resolved relative to esbuild's sourcefile + // for args.path. Using the full repo-relative path here makes esbuild + // resolve it against the file's own directory, which duplicates the + // directory segments in the final bundled source map. + const sourceName = path.basename(args.path); const sourcemap = generateNLSSourceMap(source, sourceName, edits); const encodedMap = Buffer.from(sourcemap).toString('base64'); const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`; diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts index 11f977774a5fd..98ff98a64408a 100644 --- a/build/next/private-to-property.ts +++ b/build/next/private-to-property.ts @@ -220,15 +220,53 @@ export function adjustSourceMap( return sourceMapJson; } - // Build a line-offset table for the original code to convert byte offsets to line/column - const lineStarts: number[] = [0]; - for (let i = 0; i < originalCode.length; i++) { - if (originalCode.charCodeAt(i) === 10 /* \n */) { - lineStarts.push(i + 1); + // Build line-offset tables for the original code and the code after edits. + // When edits span newlines (e.g. NLS replacing a multi-line template literal + // with `null`), subsequent lines shift up and columns change. We handle this + // by converting each mapping's old generated (line, col) to a byte offset, + // adjusting the offset for the edits, then converting back to (line, col) in + // the post-edit coordinate system. + + const oldLineStarts = buildLineStarts(originalCode); + const newLineStarts = buildLineStartsAfterEdits(originalCode, edits); + + // Precompute cumulative byte-shift after each edit for binary search + const n = edits.length; + const editStarts: number[] = new Array(n); + const editEnds: number[] = new Array(n); + const cumShifts: number[] = new Array(n); // cumulative shift *after* edit[i] + let cumShift = 0; + for (let i = 0; i < n; i++) { + editStarts[i] = edits[i].start; + editEnds[i] = edits[i].end; + cumShift += edits[i].newText.length - (edits[i].end - edits[i].start); + cumShifts[i] = cumShift; + } + + function adjustOffset(oldOff: number): number { + // Binary search: find last edit with start <= oldOff + let lo = 0, hi = n - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (editStarts[mid] <= oldOff) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // hi = index of last edit where start <= oldOff, or -1 if none + if (hi < 0) { + return oldOff; } + if (oldOff < editEnds[hi]) { + // Inside edit range — clamp to edit start in new coordinates + const prevShift = hi > 0 ? cumShifts[hi - 1] : 0; + return editStarts[hi] + prevShift; + } + return oldOff + cumShifts[hi]; } - function offsetToLineCol(offset: number): { line: number; col: number } { + function offsetToLineCol(lineStarts: readonly number[], offset: number): { line: number; col: number } { let lo = 0, hi = lineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; @@ -241,23 +279,9 @@ export function adjustSourceMap( return { line: lo, col: offset - lineStarts[lo] }; } - // Convert edits from byte offsets to per-line column shifts - interface LineEdit { col: number; origLen: number; newLen: number } - const editsByLine = new Map(); - for (const edit of edits) { - const pos = offsetToLineCol(edit.start); - const origLen = edit.end - edit.start; - let arr = editsByLine.get(pos.line); - if (!arr) { - arr = []; - editsByLine.set(pos.line, arr); - } - arr.push({ col: pos.col, origLen, newLen: edit.newText.length }); - } - // Use source-map library to read, adjust, and write const consumer = new SourceMapConsumer(sourceMapJson); - const generator = new SourceMapGenerator({ file: sourceMapJson.file }); + const generator = new SourceMapGenerator({ file: sourceMapJson.file, sourceRoot: sourceMapJson.sourceRoot }); // Copy sourcesContent for (let i = 0; i < sourceMapJson.sources.length; i++) { @@ -267,15 +291,19 @@ export function adjustSourceMap( } } - // Walk every mapping, adjust the generated column, and add to the new generator + // Walk every mapping, convert old generated position → byte offset → adjust → new position consumer.eachMapping(mapping => { - const lineEdits = editsByLine.get(mapping.generatedLine - 1); // 0-based for our data - const adjustedCol = adjustColumn(mapping.generatedColumn, lineEdits); + const oldLine0 = mapping.generatedLine - 1; // 0-based + const oldOff = (oldLine0 < oldLineStarts.length + ? oldLineStarts[oldLine0] + : oldLineStarts[oldLineStarts.length - 1]) + mapping.generatedColumn; + + const newOff = adjustOffset(oldOff); + const newPos = offsetToLineCol(newLineStarts, newOff); - // Some mappings may be unmapped (no original position/source) - skip those. if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) { const newMapping: Mapping = { - generated: { line: mapping.generatedLine, column: adjustedCol }, + generated: { line: newPos.line + 1, column: newPos.col }, original: { line: mapping.originalLine, column: mapping.originalColumn }, source: mapping.source, }; @@ -283,25 +311,82 @@ export function adjustSourceMap( newMapping.name = mapping.name; } generator.addMapping(newMapping); + } else { + // Preserve unmapped segments (generated-only mappings with no original + // position). These create essential "gaps" that prevent + // originalPositionFor() from wrongly interpolating between distant + // valid mappings on the same line in minified output. + // eslint-disable-next-line local/code-no-dangerous-type-assertions + generator.addMapping({ + generated: { line: newPos.line + 1, column: newPos.col }, + } as Mapping); } }); return JSON.parse(generator.toString()); } -function adjustColumn(col: number, lineEdits: { col: number; origLen: number; newLen: number }[] | undefined): number { - if (!lineEdits) { - return col; +function buildLineStarts(text: string): number[] { + const starts: number[] = [0]; + let pos = 0; + while (true) { + const nl = text.indexOf('\n', pos); + if (nl === -1) { + break; + } + starts.push(nl + 1); + pos = nl + 1; + } + return starts; +} + +/** + * Compute line starts for the code that results from applying `edits` to + * `originalCode`, without materialising the full new string. + */ +function buildLineStartsAfterEdits(originalCode: string, edits: readonly TextEdit[]): number[] { + const starts: number[] = [0]; + let oldPos = 0; + let newPos = 0; + + for (const edit of edits) { + // Scan unchanged region [oldPos, edit.start) for newlines + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1 || nl >= edit.start) { + break; + } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; + } + newPos += edit.start - oldPos; + + // Scan replacement text for newlines + let replFrom = 0; + while (true) { + const nl = edit.newText.indexOf('\n', replFrom); + if (nl === -1) { + break; + } + starts.push(newPos + nl + 1); + replFrom = nl + 1; + } + newPos += edit.newText.length; + + oldPos = edit.end; } - let shift = 0; - for (const edit of lineEdits) { - if (edit.col + edit.origLen <= col) { - shift += edit.newLen - edit.origLen; - } else if (edit.col < col) { - return edit.col + shift; - } else { + + // Scan remaining unchanged text after last edit + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1) { break; } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; } - return col + shift; + + return starts; } diff --git a/build/next/test/nls-sourcemap.test.ts b/build/next/test/nls-sourcemap.test.ts index fd732b8680217..c3aad2c80dcdc 100644 --- a/build/next/test/nls-sourcemap.test.ts +++ b/build/next/test/nls-sourcemap.test.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { type RawSourceMap, SourceMapConsumer } from 'source-map'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts'; +import { adjustSourceMap } from '../private-to-property.ts'; // analyzeLocalizeCalls requires the import path to end with `/nls` const NLS_STUB = [ @@ -36,7 +37,7 @@ interface BundleResult { async function bundleWithNLS( files: Record, entryPoint: string, - opts?: { postProcess?: boolean } + opts?: { postProcess?: boolean; minify?: boolean } ): Promise { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-')); const srcDir = path.join(tmpDir, 'src'); @@ -64,6 +65,7 @@ async function bundleWithNLS( packages: 'external', sourcemap: 'linked', sourcesContent: true, + minify: opts?.minify ?? false, write: false, plugins: [ nlsPlugin({ baseDir: srcDir, collector }), @@ -91,7 +93,16 @@ async function bundleWithNLS( // Optionally apply NLS post-processing (replaces placeholders with indices) if (opts?.postProcess) { const nlsResult = await finalizeNLS(collector, outDir); - jsContent = postProcessNLS(jsContent, nlsResult.indexMap, false); + const preNLSCode = jsContent; + const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false); + jsContent = nlsProcessed.code; + + // Adjust source map for NLS edits + if (nlsProcessed.edits.length > 0) { + const mapJson = JSON.parse(mapContent); + const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits); + mapContent = JSON.stringify(adjusted); + } } assert.ok(jsContent, 'Expected JS output'); @@ -209,6 +220,28 @@ suite('NLS plugin source maps', () => { } }); + test('NLS-affected nested file keeps a non-duplicated source path', async () => { + const source = [ + 'import { localize } from "../../vs/nls";', + 'export const msg = localize("myKey", "Hello World");', + ].join('\n'); + + const { mapJson, cleanup } = await bundleWithNLS( + { 'nested/deep/file.ts': source }, + 'nested/deep/file.ts', + ); + + try { + const sources: string[] = mapJson.sources ?? []; + const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts')); + assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources'); + assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'), + `Source path should not duplicate directory segments. Actual: ${nestedSource}`); + } finally { + cleanup(); + } + }); + test('line mapping correct for code after localize calls', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 @@ -370,4 +403,82 @@ suite('NLS plugin source maps', () => { cleanup(); } }); + + test('post-processed NLS - column mappings correct after placeholder replacement', async () => { + // NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their + // replacements (e.g. "0"). Without source map adjustment the columns for + // tokens AFTER the replacement drift by the cumulative length delta. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + 'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/drift.ts': source }, + 'test/drift.ts', + { postProcess: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + const bundleLine = findLine(js, 'FINDME'); + const bundleCol = findColumn(js, '"FINDME"'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 2, 'Should map to line 2'); + + const originalCol = findColumn(source, '"FINDME"'); + const columnDrift = Math.abs(pos.column! - originalCol); + assert.ok(columnDrift <= 20, + `Column drift after NLS post-processing should be small. ` + + `Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + + `Large drift means postProcessNLS edits were not applied to the source map.`); + } finally { + cleanup(); + } + }); + + test('minified bundle with NLS - end-to-end column mapping', async () => { + // With minification, the entire output is (roughly) on one line. + // Multiple NLS replacements compound their column shifts. A function + // defined after several localize() calls must still map correctly. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + '', // 2 + 'export const a = localize("k1", "Alpha message");', // 3 + 'export const b = localize("k2", "Bravo message that is quite long");', // 4 + 'export const c = localize("k3", "Charlie");', // 5 + 'export const d = localize("k4", "Delta is the fourth letter");', // 6 + '', // 7 + 'export function computeResult(x: number): number {', // 8 + '\treturn x * 42;', // 9 + '}', // 10 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/minified.ts': source }, + 'test/minified.ts', + { postProcess: true, minify: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + // Find the computeResult function in the minified output. + // esbuild minifies `x * 42` and may rename the parameter, so + // search for `*42` which survives both minification and renaming. + const needle = '*42'; + const bundleLine = findLine(js, needle); + const bundleCol = findColumn(js, needle); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source for minified mapping'); + assert.strictEqual(pos.line, 9, + `Should map "*42" back to line 9. Got line ${pos.line}.`); + } finally { + cleanup(); + } + }); }); diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts index aa9da72ce9a51..9b97679767933 100644 --- a/build/next/test/private-to-property.test.ts +++ b/build/next/test/private-to-property.test.ts @@ -439,6 +439,41 @@ suite('adjustSourceMap', () => { assert.strictEqual(pos.column, origGetValueCol, 'getValue column should match original'); }); + test('multi-line edit: removing newlines shifts subsequent lines up', () => { + // Simulates the NLS scenario: a template literal with embedded newlines + // is replaced with `null`, collapsing 3 lines into 1. + const code = [ + 'var a = "hello";', // line 0 (0-based) + 'var b = `line1', // line 1 + 'line2', // line 2 + 'line3`;', // line 3 + 'var c = "world";', // line 4 + ].join('\n'); + const map = createIdentitySourceMap(code, 'test.js'); + + // Replace the template literal `line1\nline2\nline3` with `null` + // (keeps `var b = ` and `;` intact) + const tplStart = code.indexOf('`line1'); + const tplEnd = code.indexOf('line3`') + 'line3`'.length; + const edits = [{ start: tplStart, end: tplEnd, newText: 'null' }]; + + const result = adjustSourceMap(map, code, edits); + const consumer = new SourceMapConsumer(result); + + // After edit, code is: + // "var a = \"hello\";\nvar b = null;\nvar c = \"world\";" + // "var c" was on line 5 (1-based), now on line 3 (1-based) since 2 newlines removed + + // 'var c' at original line 5, col 0 should now map at generated line 3 + const pos = consumer.originalPositionFor({ line: 3, column: 0 }); + assert.strictEqual(pos.line, 5, 'var c should map to original line 5'); + assert.strictEqual(pos.column, 0, 'var c column should be 0'); + + // 'var a' on line 1 should be unaffected + const posA = consumer.originalPositionFor({ line: 1, column: 0 }); + assert.strictEqual(posA.line, 1, 'var a should still map to original line 1'); + }); + test('brand check: #field in obj -> string replacement adjusts map', () => { const code = 'class C { #x; check(o) { return #x in o; } }'; const map = createIdentitySourceMap(code, 'test.js'); diff --git a/build/next/working.md b/build/next/working.md index b59b347611dbd..a7ea64db8b648 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -37,7 +37,7 @@ In [gulpfile.vscode.ts](../gulpfile.vscode.ts#L228-L242), the `core-ci` task use - `runEsbuildTranspile()` → transpile command - `runEsbuildBundle()` → bundle command -Old gulp-based bundling renamed to `core-ci-OLD`. +Old gulp-based bundling renamed to `core-ci-old`. --- @@ -134,7 +134,7 @@ npm run gulp vscode-reh-web-darwin-arm64-min 1. **`BUILD_INSERT_PACKAGE_CONFIGURATION`** - Server bootstrap files ([bootstrap-meta.ts](../../src/bootstrap-meta.ts)) have this marker for package.json injection. Currently handled by [inlineMeta.ts](../lib/inlineMeta.ts) in the old build's packaging step. -2. **Mangling** - The new build doesn't do TypeScript-based mangling yet. Old `core-ci` with mangling is now `core-ci-OLD`. +2. **Mangling** - The new build doesn't do TypeScript-based mangling yet. Old `core-ci` with mangling is now `core-ci-old`. 3. **Entry point duplication** - Entry points are duplicated between [buildfile.ts](../buildfile.ts) and [index.ts](index.ts). Consider consolidating. @@ -222,13 +222,13 @@ Two categories of corruption: 2. **`--source-map-base-url` option** - Rewrites `sourceMappingURL` comments to point to CDN URLs. -3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler now generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. Tests in `test/nls-sourcemap.test.ts`. +3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. `generateNLSSourceMap` adds per-column identity mappings after each edit on a line so that esbuild's source-map composition preserves fine-grained column accuracy (source maps don't interpolate columns — they use binary search, so a single boundary mapping would collapse all subsequent columns to the edit-end position). Tests in `test/nls-sourcemap.test.ts`. 4. **`convertPrivateFields` source map adjustment** (`private-to-property.ts`) - `convertPrivateFields` returns its sorted edits as `TextEdit[]`. `adjustSourceMap()` uses `SourceMapConsumer` to walk every mapping, adjusts generated columns based on cumulative edit shifts per line, and rebuilds with `SourceMapGenerator`. The post-processing loop in `index.ts` saves pre-mangle content + edits per JS file, then applies `adjustSourceMap` to the corresponding `.map`. Tests in `test/private-to-property.test.ts`. -### Not Yet Fixed +5. **`postProcessNLS` source map adjustment** (`nls-plugin.ts`, `index.ts`) — `postProcessNLS` now returns `{ code, edits }` where `edits` is a `TextEdit[]` tracking each replacement's byte offset. The bundle loop in `index.ts` chains `adjustSourceMap` calls: first for mangle edits, then for NLS edits, so both transforms are accurately reflected in the final `.map` file. Tests in `test/nls-sourcemap.test.ts`. -**`postProcessNLS` column drift** - Replaces NLS placeholders with short indices in bundled output without updating `.map` files. Shifts columns but never lines, so line-level debugging and crash reporting work correctly. Fixing would require tracking replacement offsets through regex matches and adjusting the source map, similar to `adjustSourceMap`. +6. **`adjustSourceMap` unmapped segment preservation** (`private-to-property.ts`) — Previously, `adjustSourceMap()` silently dropped mappings where `source === null`. These unmapped segments create essential "gaps" that prevent `originalPositionFor()` from wrongly interpolating between distant valid mappings on the same minified line. Now emits them as generated-only mappings. Also preserves `sourceRoot` from the input map. ### Key Technical Details @@ -241,6 +241,71 @@ Two categories of corruption: **Plugin interaction:** Both the NLS plugin and `fileContentMapperPlugin` register `onLoad({ filter: /\.ts$/ })`. In esbuild, the first `onLoad` to return non-`undefined` wins. The NLS plugin is `unshift`ed (runs first), so files with NLS calls skip `fileContentMapperPlugin`. This is safe in practice since `product.ts` (which has `BUILD->INSERT_PRODUCT_CONFIGURATION`) has no localize calls. +### Still Broken — Full Production Build (`npm run gulp vscode-min`) + +**Symptom:** Source maps are totally broken in the minified production build. E.g. a breakpoint at `src/vs/editor/browser/editorExtensions.ts` line 308 resolves to `src/vs/editor/common/cursor/cursorMoveCommands.ts` line 732 — a completely different file. This is **cross-file** mapping corruption, not just column drift. + +**Status of unit tests:** The fixes above pass in isolated unit tests (small 1–2 file bundles via `esbuild.build` with `minify: true`). The tests verify column drift ≤ 20 and correct line mapping for single-file bundles with NLS. **183 tests pass, 0 failing.** But the full production build bundles hundreds of files into huge minified outputs (e.g. `workbench.desktop.main.js` at ~15 MB) and the source maps break at that scale. + +**Suspected root causes (need investigation):** + +1. **`generateNLSSourceMap` per-column identity mappings may overwhelm esbuild's source-map composition.** The fix added one mapping per column from edit-end to end-of-line (or next edit). For a long TypeScript line with a `localize()` call near the beginning, this generates hundreds of identity mappings per line. Across hundreds of files, the inline source maps embedded in `onLoad` responses may be extremely large. esbuild must compose these with its own source maps during bundling — it may hit limits, silently drop mappings, or produce incorrect composed maps at this scale. **Mitigation to try:** Instead of per-column mappings, use sparser "checkpoint" mappings (e.g., every N characters) or rely only on boundary mappings and accept some column drift within the NLS-transformed region. The old boundary-only approach was wrong (collapsed all downstream columns), but per-column may be the other extreme. + +2. **`adjustSourceMap` may corrupt source indices in large minified bundles.** In a minified bundle, the entire output is on one or very few lines. `adjustSourceMap()` walks every mapping via `SourceMapConsumer.eachMapping()` and adjusts `generatedColumn` using `adjustColumn()`. But when thousands of mappings all share `generatedLine: 1` and there are hundreds of NLS edits on that same line, there may be sorting/ordering bugs: `eachMapping()` returns mappings in generated order by default, but `adjustColumn()` binary-searches through edits sorted by column. If edits cover regions that interleave with mappings from different source files, the cumulative shift calculation might produce wrong columns that then resolve to wrong source files. + +3. **Chained `adjustSourceMap` calls (mangle → NLS) may compound errors.** After the first `adjustSourceMap` for mangle edits, the source map's generated columns are updated. The second call for NLS edits uses `nlsEdits` which were computed against `preNLSCode` — but `preNLSCode` is the post-mangle JS, which is what the first `adjustSourceMap` maps from. This chaining _should_ be correct, but needs verification at scale with a real minified bundle. + +4. **The `source-map` v0.6.1 library may have precision issues with very large VLQ-encoded maps.** The bundled outputs have source maps with hundreds of thousands of mappings. The library is old (2017) and there may be numerical precision or sorting issues with very large maps. Consider testing with `source-map` v0.7+ or the Rust-based `@aspect-build/source-map`. + +5. **Alternative approach: skip per-column NLS plugin mappings, fix only `postProcessNLS`.** The NLS plugin `onLoad` replaces `"key"` with `"%%NLS:longPlaceholder%%"` — a length change that only affects columns on affected lines. The subsequent `postProcessNLS` then replaces the long placeholder with a short index. If the `adjustSourceMap` for `postProcessNLS` is correct, it should compensate for both expansions (plugin expansion + post-process contraction). We might not need per-column mappings in `generateNLSSourceMap` at all — just the boundary mapping. The column will drift in the intermediate representation but `adjustSourceMap` for NLS should fix it. **This hypothesis needs testing.** + +6. **Alternative approach: do NLS replacement purely in post-processing.** Skip the `onLoad` two-phase approach (placeholder insertion + post-processing replacement) entirely. Instead, run `postProcessNLS` as a single post-processing step that directly replaces `localize("key", "message")` → `localize(0, null)` in the bundled JS output, with proper source-map adjustment via `adjustSourceMap`. This avoids both the inline source map composition complexity and the two-step replacement. The downside is that post-processing must parse/regex-match real `localize()` calls (not easy placeholders), which is more fragile. + +**Summary of fixes applied vs status:** + +| Bug | Fix | Unit test | Production | +|-----|-----|-----------|------------| +| `generateNLSSourceMap` only had boundary mappings → columns collapsed | Added per-column identity mappings after each edit | Pass (drift: 0) | **Broken** — may overwhelm esbuild composition at scale | +| `postProcessNLS` didn't track edits for source map adjustment | Returns `{ code, edits }`, chained in `index.ts` | Pass | **Broken** — `adjustSourceMap` may corrupt source indices on huge single-line minified output | +| `adjustSourceMap` dropped unmapped segments | Preserves generated-only mappings + `sourceRoot` | Pass (no regressions) | **Broken** — same cross-file mapping issue | + +**Files involved:** +- `build/next/nls-plugin.ts` — `generateNLSSourceMap()` (per-column mappings), `postProcessNLS()` (returns edits), `replaceInOutput()` (regex replacement) +- `build/next/private-to-property.ts` — `adjustSourceMap()` (column adjustment) +- `build/next/index.ts` — bundle post-processing loop (lines ~899–975), chains adjustSourceMap calls +- `build/next/test/nls-sourcemap.test.ts` — unit tests (pass but don't cover production-scale bundles) + +**How to reproduce:** +```bash +npm run gulp vscode-min +# Open out-vscode-min/ in a debugger, set breakpoints in editor files +# Observe breakpoints resolve to wrong files +``` + +**How to debug further:** +```bash +# 1. Build with just --nls (no mangle) to isolate NLS from mangle issues +npx tsx build/next/index.ts bundle --nls --minify --target desktop --out out-debug + +# 2. Build with just --mangle-privates (no NLS) to isolate mangle issues +npx tsx build/next/index.ts bundle --mangle-privates --minify --target desktop --out out-debug + +# 3. Build with neither (baseline — does esbuild's own map work?) +npx tsx build/next/index.ts bundle --minify --target desktop --out out-debug + +# 4. Compare .map files across the three builds to find where mappings diverge + +# 5. Validate a specific mapping in the large bundle: +node -e " +const {SourceMapConsumer} = require('source-map'); +const fs = require('fs'); +const map = JSON.parse(fs.readFileSync('./out-debug/vs/workbench/workbench.desktop.main.js.map','utf8')); +const c = new SourceMapConsumer(map); +// Look up a known position and see which source file it resolves to +console.log(c.originalPositionFor({line: 1, column: XXXX})); +" +``` + --- ## Self-hosting Setup diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts index 48d76e2731a6e..b56884af25c52 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -60,6 +60,7 @@ export const dirs = [ 'test/mcp', '.vscode/extensions/vscode-selfhost-import-aid', '.vscode/extensions/vscode-selfhost-test-provider', + '.vscode/extensions/vscode-extras', ]; if (existsSync(`${import.meta.dirname}/../../.build/distro/npm`)) { diff --git a/build/npm/fast-install.ts b/build/npm/fast-install.ts new file mode 100644 index 0000000000000..ff9a7d2097cf2 --- /dev/null +++ b/build/npm/fast-install.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as child_process from 'child_process'; +import { root, isUpToDate, forceInstallMessage } from './installStateHash.ts'; + +if (!process.argv.includes('--force') && isUpToDate()) { + console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`); + process.exit(0); +} + +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const result = child_process.spawnSync(npm, ['install'], { + cwd: root, + stdio: 'inherit', + shell: true, + env: { ...process.env, VSCODE_FORCE_INSTALL: '1' }, +}); + +process.exit(result.status ?? 1); diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 6e28e550f4699..8c499c6740fa9 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -526,13 +526,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts new file mode 100644 index 0000000000000..0b3d9898015d6 --- /dev/null +++ b/build/npm/installStateHash.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import path from 'path'; +import { dirs } from './dirs.ts'; + +export const root = fs.realpathSync.native(path.dirname(path.dirname(import.meta.dirname))); +export const stateFile = path.join(root, 'node_modules', '.postinstall-state'); +export const stateContentsFile = path.join(root, 'node_modules', '.postinstall-state-contents'); +export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts --force\x1b[0m to force a full install.'; + +export function collectInputFiles(): string[] { + const files: string[] = []; + + for (const dir of dirs) { + const base = dir === '' ? root : path.join(root, dir); + for (const file of ['package.json', 'package-lock.json', '.npmrc']) { + const filePath = path.join(base, file); + if (fs.existsSync(filePath)) { + files.push(filePath); + } + } + } + + files.push(path.join(root, '.nvmrc')); + + return files; +} + +export interface PostinstallState { + readonly nodeVersion: string; + readonly fileHashes: Record; +} + +const packageJsonRelevantKeys = new Set([ + 'name', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'overrides', + 'engines', + 'workspaces', + 'bundledDependencies', + 'bundleDependencies', +]); + +const packageLockJsonIgnoredKeys = new Set(['version']); + +function normalizeFileContent(filePath: string): string { + const raw = fs.readFileSync(filePath, 'utf8'); + const basename = path.basename(filePath); + if (basename === 'package.json') { + const json = JSON.parse(raw); + const filtered: Record = {}; + for (const key of packageJsonRelevantKeys) { + // eslint-disable-next-line local/code-no-in-operator + if (key in json) { + filtered[key] = json[key]; + } + } + return JSON.stringify(filtered, null, '\t') + '\n'; + } + if (basename === 'package-lock.json') { + const json = JSON.parse(raw); + for (const key of packageLockJsonIgnoredKeys) { + delete json[key]; + } + if (json.packages?.['']) { + for (const key of packageLockJsonIgnoredKeys) { + delete json.packages[''][key]; + } + } + return JSON.stringify(json, null, '\t') + '\n'; + } + return raw; +} + +function hashContent(content: string): string { + const hash = crypto.createHash('sha256'); + hash.update(content); + return hash.digest('hex'); +} + +export function computeState(options?: { ignoreNodeVersion?: boolean }): PostinstallState { + const fileHashes: Record = {}; + for (const filePath of collectInputFiles()) { + const key = path.relative(root, filePath); + try { + fileHashes[key] = hashContent(normalizeFileContent(filePath)); + } catch { + // file may not be readable + } + } + return { nodeVersion: options?.ignoreNodeVersion ? '' : process.versions.node, fileHashes }; +} + +export function computeContents(): Record { + const fileContents: Record = {}; + for (const filePath of collectInputFiles()) { + try { + fileContents[path.relative(root, filePath)] = normalizeFileContent(filePath); + } catch { + // file may not be readable + } + } + return fileContents; +} + +export function readSavedState(): PostinstallState | undefined { + try { + const { nodeVersion, fileHashes } = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + return { nodeVersion, fileHashes }; + } catch { + return undefined; + } +} + +export function isUpToDate(): boolean { + const saved = readSavedState(); + if (!saved) { + return false; + } + const current = computeState(); + return saved.nodeVersion === current.nodeVersion + && JSON.stringify(saved.fileHashes) === JSON.stringify(current.fileHashes); +} + +export function readSavedContents(): Record | undefined { + try { + return JSON.parse(fs.readFileSync(stateContentsFile, 'utf8')); + } catch { + return undefined; + } +} + +// When run directly, output state as JSON for tooling (e.g. the vscode-extras extension). +if (import.meta.filename === process.argv[1]) { + const args = new Set(process.argv.slice(2)); + + if (args.has('--normalize-file')) { + const filePath = process.argv[process.argv.indexOf('--normalize-file') + 1]; + if (!filePath) { + process.exit(1); + } + process.stdout.write(normalizeFileContent(filePath)); + } else { + const ignoreNodeVersion = args.has('--ignore-node-version'); + const current = computeState({ ignoreNodeVersion }); + const saved = readSavedState(); + console.log(JSON.stringify({ + root, + stateContentsFile, + current, + saved: saved && ignoreNodeVersion ? { nodeVersion: '', fileHashes: saved.fileHashes } : saved, + files: [...collectInputFiles(), stateFile], + })); + } +} diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index b6a934f74b3eb..db659fa78a423 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -8,9 +8,9 @@ import path from 'path'; import * as os from 'os'; import * as child_process from 'child_process'; import { dirs } from './dirs.ts'; +import { root, stateFile, stateContentsFile, computeState, computeContents, isUpToDate } from './installStateHash.ts'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; -const root = path.dirname(path.dirname(import.meta.dirname)); const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc')); function log(dir: string, message: string) { @@ -35,24 +35,45 @@ function run(command: string, args: string[], opts: child_process.SpawnSyncOptio } } -function npmInstall(dir: string, opts?: child_process.SpawnSyncOptions) { - opts = { +function spawnAsync(command: string, args: string[], opts: child_process.SpawnOptions): Promise { + return new Promise((resolve, reject) => { + const child = child_process.spawn(command, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout?.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr?.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('error', reject); + child.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Process exited with code: ${code}\n${output}`)); + } else { + resolve(output); + } + }); + }); +} + +async function npmInstallAsync(dir: string, opts?: child_process.SpawnOptions): Promise { + const finalOpts: child_process.SpawnOptions = { env: { ...process.env }, ...(opts ?? {}), - cwd: dir, - stdio: 'inherit', - shell: true + cwd: path.join(root, dir), + shell: true, }; const command = process.env['npm_command'] || 'install'; if (process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'] && /^(.build\/distro\/npm\/)?remote$/.test(dir)) { + const syncOpts: child_process.SpawnSyncOptions = { + env: finalOpts.env, + cwd: root, + stdio: 'inherit', + shell: true, + }; const userinfo = os.userInfo(); log(dir, `Installing dependencies inside container ${process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME']}...`); - opts.cwd = root; if (process.env['npm_config_arch'] === 'arm64') { - run('sudo', ['docker', 'run', '--rm', '--privileged', 'multiarch/qemu-user-static', '--reset', '-p', 'yes'], opts); + run('sudo', ['docker', 'run', '--rm', '--privileged', 'multiarch/qemu-user-static', '--reset', '-p', 'yes'], syncOpts); } run('sudo', [ 'docker', 'run', @@ -63,11 +84,16 @@ function npmInstall(dir: string, opts?: child_process.SpawnSyncOptions) { '-w', path.resolve('/root/vscode', dir), process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'], 'sh', '-c', `\"chown -R root:root ${path.resolve('/root/vscode', dir)} && export PATH="/root/vscode/.build/nodejs-musl/usr/local/bin:$PATH" && npm i -g node-gyp-build && npm ci\"` - ], opts); - run('sudo', ['chown', '-R', `${userinfo.uid}:${userinfo.gid}`, `${path.resolve(root, dir)}`], opts); + ], syncOpts); + run('sudo', ['chown', '-R', `${userinfo.uid}:${userinfo.gid}`, `${path.resolve(root, dir)}`], syncOpts); } else { log(dir, 'Installing dependencies...'); - run(npm, command.split(' '), opts); + const output = await spawnAsync(npm, command.split(' '), finalOpts); + if (output.trim()) { + for (const line of output.trim().split('\n')) { + log(dir, line); + } + } } removeParcelWatcherPrebuild(dir); } @@ -156,65 +182,172 @@ function clearInheritedNpmrcConfig(dir: string, env: NodeJS.ProcessEnv): void { } } -for (const dir of dirs) { +function ensureAgentHarnessLink(sourceRelativePath: string, linkPath: string): 'existing' | 'junction' | 'symlink' | 'hard link' { + if (fs.existsSync(linkPath)) { + return 'existing'; + } - if (dir === '') { - removeParcelWatcherPrebuild(dir); - continue; // already executed in root + const sourcePath = path.resolve(path.dirname(linkPath), sourceRelativePath); + const isDirectory = fs.statSync(sourcePath).isDirectory(); + + try { + if (process.platform === 'win32' && isDirectory) { + fs.symlinkSync(sourcePath, linkPath, 'junction'); + return 'junction'; + } + + fs.symlinkSync(sourceRelativePath, linkPath, isDirectory ? 'dir' : 'file'); + return 'symlink'; + } catch (error) { + if (process.platform === 'win32' && !isDirectory && (error as NodeJS.ErrnoException).code === 'EPERM') { + fs.linkSync(sourcePath, linkPath); + return 'hard link'; + } + + throw error; } +} - let opts: child_process.SpawnSyncOptions | undefined; +async function runWithConcurrency(tasks: (() => Promise)[], concurrency: number): Promise { + const errors: Error[] = []; + let index = 0; - if (dir === 'build') { - opts = { - env: { - ...process.env - }, - }; - if (process.env['CC']) { opts.env!['CC'] = 'gcc'; } - if (process.env['CXX']) { opts.env!['CXX'] = 'g++'; } - if (process.env['CXXFLAGS']) { opts.env!['CXXFLAGS'] = ''; } - if (process.env['LDFLAGS']) { opts.env!['LDFLAGS'] = ''; } - - setNpmrcConfig('build', opts.env!); - npmInstall('build', opts); - continue; - } - - if (/^(.build\/distro\/npm\/)?remote$/.test(dir)) { - // node modules used by vscode server - opts = { - env: { - ...process.env - }, - }; - if (process.env['VSCODE_REMOTE_CC']) { - opts.env!['CC'] = process.env['VSCODE_REMOTE_CC']; - } else { - delete opts.env!['CC']; + async function worker() { + while (index < tasks.length) { + const i = index++; + try { + await tasks[i](); + } catch (err) { + errors.push(err as Error); + } + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())); + + if (errors.length > 0) { + for (const err of errors) { + console.error(err.message); + } + process.exit(1); + } +} + +async function main() { + if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) { + log('.', 'All dependencies up to date, skipping postinstall.'); + child_process.execSync('git config pull.rebase merges'); + child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); + return; + } + + const _state = computeState(); + + const nativeTasks: (() => Promise)[] = []; + const parallelTasks: (() => Promise)[] = []; + + for (const dir of dirs) { + if (dir === '') { + removeParcelWatcherPrebuild(dir); + continue; // already executed in root } - if (process.env['VSCODE_REMOTE_CXX']) { - opts.env!['CXX'] = process.env['VSCODE_REMOTE_CXX']; - } else { - delete opts.env!['CXX']; + + if (dir === 'build') { + nativeTasks.push(() => { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (process.env['CC']) { env['CC'] = 'gcc'; } + if (process.env['CXX']) { env['CXX'] = 'g++'; } + if (process.env['CXXFLAGS']) { env['CXXFLAGS'] = ''; } + if (process.env['LDFLAGS']) { env['LDFLAGS'] = ''; } + setNpmrcConfig('build', env); + return npmInstallAsync('build', { env }); + }); + continue; } - if (process.env['CXXFLAGS']) { delete opts.env!['CXXFLAGS']; } - if (process.env['CFLAGS']) { delete opts.env!['CFLAGS']; } - if (process.env['LDFLAGS']) { delete opts.env!['LDFLAGS']; } - if (process.env['VSCODE_REMOTE_CXXFLAGS']) { opts.env!['CXXFLAGS'] = process.env['VSCODE_REMOTE_CXXFLAGS']; } - if (process.env['VSCODE_REMOTE_LDFLAGS']) { opts.env!['LDFLAGS'] = process.env['VSCODE_REMOTE_LDFLAGS']; } - if (process.env['VSCODE_REMOTE_NODE_GYP']) { opts.env!['npm_config_node_gyp'] = process.env['VSCODE_REMOTE_NODE_GYP']; } - - setNpmrcConfig('remote', opts.env!); - npmInstall(dir, opts); - continue; - } - - // For directories that don't define their own .npmrc, clear inherited config - const env = { ...process.env }; - clearInheritedNpmrcConfig(dir, env); - npmInstall(dir, { env }); + + if (/^(.build\/distro\/npm\/)?remote$/.test(dir)) { + const remoteDir = dir; + nativeTasks.push(() => { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (process.env['VSCODE_REMOTE_CC']) { + env['CC'] = process.env['VSCODE_REMOTE_CC']; + } else { + delete env['CC']; + } + if (process.env['VSCODE_REMOTE_CXX']) { + env['CXX'] = process.env['VSCODE_REMOTE_CXX']; + } else { + delete env['CXX']; + } + if (process.env['CXXFLAGS']) { delete env['CXXFLAGS']; } + if (process.env['CFLAGS']) { delete env['CFLAGS']; } + if (process.env['LDFLAGS']) { delete env['LDFLAGS']; } + if (process.env['VSCODE_REMOTE_CXXFLAGS']) { env['CXXFLAGS'] = process.env['VSCODE_REMOTE_CXXFLAGS']; } + if (process.env['VSCODE_REMOTE_LDFLAGS']) { env['LDFLAGS'] = process.env['VSCODE_REMOTE_LDFLAGS']; } + if (process.env['VSCODE_REMOTE_NODE_GYP']) { env['npm_config_node_gyp'] = process.env['VSCODE_REMOTE_NODE_GYP']; } + setNpmrcConfig('remote', env); + return npmInstallAsync(remoteDir, { env }); + }); + continue; + } + + const taskDir = dir; + parallelTasks.push(() => { + const env = { ...process.env }; + clearInheritedNpmrcConfig(taskDir, env); + return npmInstallAsync(taskDir, { env }); + }); + } + + // Native dirs (build, remote) run sequentially to avoid node-gyp conflicts + for (const task of nativeTasks) { + await task(); + } + + // JS-only dirs run in parallel + const concurrency = Math.min(os.cpus().length, 8); + log('.', `Running ${parallelTasks.length} npm installs with concurrency ${concurrency}...`); + await runWithConcurrency(parallelTasks, concurrency); + + child_process.execSync('git config pull.rebase merges'); + child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); + + fs.writeFileSync(stateFile, JSON.stringify(_state)); + fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); + + // Symlink .claude/ files to their canonical locations to test Claude agent harness + const claudeDir = path.join(root, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeMdLink = path.join(claudeDir, 'CLAUDE.md'); + const claudeMdLinkType = ensureAgentHarnessLink(path.join('..', '.github', 'copilot-instructions.md'), claudeMdLink); + if (claudeMdLinkType !== 'existing') { + log('.', `Created ${claudeMdLinkType} .claude/CLAUDE.md -> .github/copilot-instructions.md`); + } + + const claudeSkillsLink = path.join(claudeDir, 'skills'); + const claudeSkillsLinkType = ensureAgentHarnessLink(path.join('..', '.agents', 'skills'), claudeSkillsLink); + if (claudeSkillsLinkType !== 'existing') { + log('.', `Created ${claudeSkillsLinkType} .claude/skills -> .agents/skills`); + } + + // Temporary: patch @github/copilot-sdk session.js to fix ESM import + // (missing .js extension on vscode-jsonrpc/node). Fixed upstream in v0.1.32. + // TODO: Remove once @github/copilot-sdk is updated to >=0.1.32 + for (const dir of ['', 'remote']) { + const sessionFile = path.join(root, dir, 'node_modules', '@github', 'copilot-sdk', 'dist', 'session.js'); + if (fs.existsSync(sessionFile)) { + const content = fs.readFileSync(sessionFile, 'utf8'); + const patched = content.replace(/from "vscode-jsonrpc\/node"/g, 'from "vscode-jsonrpc/node.js"'); + if (content !== patched) { + fs.writeFileSync(sessionFile, patched); + log(dir || '.', 'Patched @github/copilot-sdk session.js (vscode-jsonrpc ESM import fix)'); + } + } + } } -child_process.execSync('git config pull.rebase merges'); -child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/build/npm/preinstall.ts b/build/npm/preinstall.ts index 3476fcabb5009..82d1919e4545e 100644 --- a/build/npm/preinstall.ts +++ b/build/npm/preinstall.ts @@ -6,6 +6,7 @@ import path from 'path'; import * as fs from 'fs'; import * as child_process from 'child_process'; import * as os from 'os'; +import { isUpToDate, forceInstallMessage } from './installStateHash.ts'; if (!process.env['VSCODE_SKIP_NODE_VERSION_CHECK']) { // Get the running Node.js version @@ -28,10 +29,10 @@ if (!process.env['VSCODE_SKIP_NODE_VERSION_CHECK']) { const requiredMinor = parseInt(requiredVersionMatch[2]); const requiredPatch = parseInt(requiredVersionMatch[3]); - if (majorNodeVersion < requiredMajor || - (majorNodeVersion === requiredMajor && minorNodeVersion < requiredMinor) || - (majorNodeVersion === requiredMajor && minorNodeVersion === requiredMinor && patchNodeVersion < requiredPatch)) { - console.error(`\x1b[1;31m*** Please use Node.js v${requiredVersion} or later for development. Currently using v${process.versions.node}.\x1b[0;0m`); + if (majorNodeVersion !== requiredMajor || + minorNodeVersion < requiredMinor || + (minorNodeVersion === requiredMinor && patchNodeVersion < requiredPatch)) { + console.error(`\x1b[1;31m*** Please use Node.js v${requiredVersion} or newer with the same major version (${requiredMajor}) as specified in .nvmrc. Currently using v${process.versions.node}.\x1b[0;0m`); throw new Error(); } } @@ -41,6 +42,13 @@ if (process.env.npm_execpath?.includes('yarn')) { throw new Error(); } +// Fast path: if nothing changed since last successful install, skip everything. +// This makes `npm i` near-instant when dependencies haven't changed. +if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) { + console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`); + process.exit(0); +} + if (process.platform === 'win32') { if (!hasSupportedVisualStudioVersion()) { console.error('\x1b[1;31m*** Invalid C/C++ Compiler Toolchain. Please check https://github.com/microsoft/vscode/wiki/How-to-Contribute#prerequisites.\x1b[0;0m'); diff --git a/build/npm/update-localization-extension.ts b/build/npm/update-localization-extension.ts index cb7981b9388ed..45371dd9cd0d6 100644 --- a/build/npm/update-localization-extension.ts +++ b/build/npm/update-localization-extension.ts @@ -120,7 +120,7 @@ function update(options: Options) { }); }); } -if (path.basename(process.argv[1]) === 'update-localization-extension.js') { +if (path.basename(process.argv[1]) === 'update-localization-extension.ts') { const options = minimist(process.argv.slice(2), { string: ['location', 'externalExtensionsLocation'] }) as Options; diff --git a/build/package-lock.json b/build/package-lock.json index b78c4c8389ac5..cc1acf90b9798 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -48,7 +48,7 @@ "@types/workerpool": "^6.4.0", "@types/xml2js": "0.0.33", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/ripgrep": "^1.15.13", + "@vscode/ripgrep": "^1.17.1", "@vscode/vsce": "3.6.1", "ansi-colors": "^3.2.3", "byline": "^5.0.0", @@ -1027,29 +1027,6 @@ "node": ">=18" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1950,9 +1927,9 @@ "license": "MIT" }, "node_modules/@vscode/ripgrep": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", - "integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.1.tgz", + "integrity": "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2170,6 +2147,29 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@vscode/vsce/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2232,16 +2232,16 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2318,9 +2318,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -3493,10 +3493,26 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", "dev": true, "funding": [ { @@ -3506,7 +3522,9 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4512,9 +4530,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { @@ -4654,10 +4672,11 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5085,6 +5104,22 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6130,9 +6165,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "dev": true, "funding": [ { @@ -6541,9 +6576,9 @@ "license": "MIT" }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT" }, @@ -6743,12 +6778,13 @@ } }, "node_modules/vscode-universal-bundler/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" diff --git a/build/package.json b/build/package.json index 785f04f3b22e3..8a65120c4d60e 100644 --- a/build/package.json +++ b/build/package.json @@ -42,7 +42,7 @@ "@types/workerpool": "^6.4.0", "@types/xml2js": "0.0.33", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/ripgrep": "^1.15.13", + "@vscode/ripgrep": "^1.17.1", "@vscode/vsce": "3.6.1", "ansi-colors": "^3.2.3", "byline": "^5.0.0", diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 4179138e714c7..8d0937b8faf75 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,13 +8,166 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-24", + "@vscode/component-explorer-vite-plugin": "^0.1.1-24", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" } }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-2.2.0.tgz", + "integrity": "sha512-9UAZqn8ywdR70n3GwVle4N8ALosQs4z50N7XMXrSTUVOmVpaBC5kE3TRTT7qQdi3OaQV24mjGuJZsHUmhD+ZXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^1.0.3", + "@octokit/graphql": "^4.3.1", + "@octokit/rest": "^16.43.1" + } + }, + "node_modules/@actions/http-client": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz", + "integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -49,6 +202,65 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@hediet/semver/-/semver-0.2.2.tgz", + "integrity": "sha512-sdH+TwXwaYOgnKij3QQbJERl2HkJ+l8idWINwHBI+8nXl1yuTCMerDLDPC48t1wbr849qBTpJTV1EJXlh7OGAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.0.4", + "@actions/github": "^2.2.0", + "@typescript-eslint/eslint-plugin": "^3.0.1", + "@typescript-eslint/parser": "^3.0.1", + "eslint": "^7.1.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -66,6 +278,322 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/core/node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/core/node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@octokit/core/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz", + "integrity": "sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz", + "integrity": "sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1", + "deprecation": "^2.3.1" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/rest": { + "version": "16.43.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.2.tgz", + "integrity": "sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^2.4.0", + "@octokit/plugin-paginate-rest": "^1.1.1", + "@octokit/plugin-request-log": "^1.0.0", + "@octokit/plugin-rest-endpoint-methods": "2.4.0", + "@octokit/request": "^5.2.0", + "@octokit/request-error": "^1.0.2", + "atob-lite": "^2.0.0", + "before-after-hook": "^2.0.0", + "btoa-lite": "^1.0.0", + "deprecation": "^2.0.0", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.uniq": "^4.5.0", + "octokit-pagination-methods": "^1.1.0", + "once": "^1.4.0", + "universal-user-agent": "^4.0.0" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request-error": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz", + "integrity": "sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/rest/node_modules/universal-user-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz", + "integrity": "sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==", + "dev": true, + "license": "ISC", + "dependencies": { + "os-name": "^3.1.0" + } + }, + "node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, "node_modules/@oxc-project/runtime": { "version": "0.101.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.101.0.tgz", @@ -315,9 +843,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -329,9 +857,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -343,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -357,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -371,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -385,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -399,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -413,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -427,9 +955,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -441,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -455,9 +983,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -469,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -483,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -497,9 +1025,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -511,9 +1039,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -525,9 +1053,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -539,9 +1067,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -553,9 +1081,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -567,9 +1095,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -581,9 +1109,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -595,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -609,9 +1137,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -623,9 +1151,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -637,9 +1165,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -651,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -675,6 +1203,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -682,82 +1217,1131 @@ "dev": true, "license": "MIT" }, - "node_modules/@vscode/component-explorer": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-12.tgz", - "integrity": "sha512-qqbxbu3BvqWtwFdVsROLUSd1BiScCiUPP5n0sk0yV1WDATlAl6wQMX1QlmsZy3hag8iP/MXUEj5tSBjA1T7tFw==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "dev": true, + "license": "MIT", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "undici-types": "~7.18.0" } }, - "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-12.tgz", - "integrity": "sha512-MG5ndoooX2X9PYto1WkNSwWKKmR5OJx3cBnUf7JHm8ERw+8RsZbLe+WS+hVOqnCVPxHy7t+0IYRFl7IC5cuwOQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz", + "integrity": "sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ==", "dev": true, + "license": "MIT", "dependencies": { - "tinyglobby": "^0.2.0" + "@typescript-eslint/experimental-utils": "3.10.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@vscode/component-explorer": "*", - "vite": "*" + "@typescript-eslint/parser": "^3.0.0", + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@vscode/rollup-plugin-esm-url": { - "version": "1.0.1-1", - "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-1.tgz", + "node_modules/@typescript-eslint/experimental-utils": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", + "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz", + "integrity": "sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "3.10.1", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", + "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", + "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/visitor-keys": "3.10.1", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", + "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vscode/component-explorer": { + "version": "0.1.1-27", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-27.tgz", + "integrity": "sha512-FAxC9WlYOaRx6XfU3/ceI2bCOPQp45CpQehUyhG3AbDxLuM8Kv2VyJYJiSsQ0Z2a8cah/3CB729oIiINO19mdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hediet/semver": "^0.2.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@vscode/component-explorer-vite-plugin": { + "version": "0.1.1-27", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-27.tgz", + "integrity": "sha512-wX2Z9e5E3ZdiPRTRIUYOBaReYOfGXd+iseNPAcdfx8gNKJiXrceco4gdKCQv3+WEyvLI3uT/oGdP9ecDcR6mbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hediet/semver": "^0.2.2", + "tinyglobby": "^0.2.0" + }, + "peerDependencies": { + "@vscode/component-explorer": "*", + "vite": "*" + } + }, + "node_modules/@vscode/rollup-plugin-esm-url": { + "version": "1.0.1-1", + "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-1.tgz", "integrity": "sha512-vNmIR3ZyiwACUi8qnXhKNukoXaFkOM9skiqVOVHNKJTBb7kJS+evtyadrBc/fMm1y303WQWBNA90E7fCCsE2Sw==", "dev": true, "license": "MIT", - "peerDependencies": { - "rollup": "^3.0.0 || ^4.0.0" + "peerDependencies": { + "rollup": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/atob-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", + "integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=6" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -765,6 +2349,73 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -1026,6 +2677,49 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1033,29 +2727,212 @@ "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/macos-release": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", + "integrity": "sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/octokit-pagination-methods": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz", + "integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-name": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", + "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "macos-release": "^2.2.0", + "windows-release": "^3.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">=6" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/picocolors": { @@ -1107,6 +2984,47 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1134,6 +3052,56 @@ "react": "^18.3.1" } }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.53.tgz", @@ -1167,9 +3135,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1183,31 +3151,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -1221,6 +3189,67 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1231,6 +3260,125 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1248,6 +3396,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1256,6 +3411,111 @@ "license": "0BSD", "optional": true }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", + "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.3.1", @@ -1332,6 +3592,73 @@ "optional": true } } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/windows-release": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.3.3.tgz", + "integrity": "sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" } } } diff --git a/build/vite/package.json b/build/vite/package.json index 245bf4fc8001a..05a96fa1ffd84 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-24", + "@vscode/component-explorer-vite-plugin": "^0.1.1-24", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/build/vite/vite.config.ts b/build/vite/vite.config.ts index cdae205f030df..24aaa12c02655 100644 --- a/build/vite/vite.config.ts +++ b/build/vite/vite.config.ts @@ -143,11 +143,8 @@ const logger = createLogger(); const loggerWarn = logger.warn; logger.warn = (msg, options) => { - // amdX and the baseUrl code cannot be analyzed by vite. - // However, they are not needed, so it is okay to silence the warning. - if (msg.indexOf('vs/amdX.ts') !== -1) { - return; - } + // the baseUrl code cannot be analyzed by vite. + // However, it is not needed, so it is okay to silence the warning. if (msg.indexOf('await import(new URL(`vs/workbench/workbench.desktop.main.js`, baseUrl).href)') !== -1) { return; } diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index d35c41e4098f9..bc102802568e8 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -3,93 +3,141 @@ version = 4 [[package]] -name = "bitflags" -version = "1.3.2" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc" -version = "3.0.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crossbeam-channel" -version = "0.5.5" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.10" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" -dependencies = [ - "cfg-if", - "once_cell", -] +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "dirs-next" -version = "2.0.0" +name = "deranged" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ - "cfg-if", - "dirs-sys-next", + "powerfmt", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" dependencies = [ - "libc", - "redox_users", - "winapi", + "serde", ] [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -98,38 +146,103 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" -version = "0.2.7" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "getrandom" -version = "0.3.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] [[package]] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" dependencies = [ "byteorder", "crc", @@ -142,40 +255,74 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "itoa" -version = "1.0.2" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "num_threads" -version = "0.1.6" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "libc", + "autocfg", ] [[package]] @@ -184,20 +331,36 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.40" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.20" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -209,55 +372,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "redox_syscall" -version = "0.2.13" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 1.3.2", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] -name = "redox_users" -version = "0.4.3" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "getrandom 0.2.7", - "redox_syscall", - "thiserror", + "serde_core", ] [[package]] -name = "rustix" -version = "1.0.7" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "serde_derive", ] [[package]] -name = "rustversion" -version = "1.0.7" +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slog" -version = "2.7.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] [[package]] name = "slog-async" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766c59b252e62a34651412870ff55d8c4e6d04df19b43eecb2703e417b097ffe" +checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84" dependencies = [ "crossbeam-channel", "slog", @@ -267,10 +470,11 @@ dependencies = [ [[package]] name = "slog-term" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6e022d0b998abfe5c3782c1f03551a596269450ccd677ea51c56f8b214610e8" +checksum = "5cb1fc680b38eed6fad4c02b3871c09d2c81db8c96aa4e9c0a34904c830f09b5" dependencies = [ + "chrono", "is-terminal", "slog", "term", @@ -280,9 +484,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.98" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -297,156 +501,256 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "term" -version = "0.7.0" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ - "dirs-next", + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", "rustversion", - "winapi", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "thiserror" -version = "1.0.31" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "thiserror-impl", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "thiserror-impl" -version = "1.0.31" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-shared", ] [[package]] -name = "thread_local" -version = "1.1.4" +name = "wasm-bindgen-shared" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ - "once_cell", + "unicode-ident", ] [[package]] -name = "time" -version = "0.3.11" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "itoa", - "libc", - "num_threads", - "time-macros", + "leb128fmt", + "wasmparser", ] [[package]] -name = "time-macros" -version = "0.2.4" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] [[package]] -name = "unicode-ident" -version = "1.0.1" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "wit-bindgen-rt", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] [[package]] -name = "windows-sys" -version = "0.42.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows-targets", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "windows-targets" -version = "0.52.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows-link", ] [[package]] @@ -455,24 +759,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -480,70 +772,119 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] -name = "windows_i686_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.5" +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] -name = "windows_i686_msvc" +name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] -name = "windows_i686_msvc" -version = "0.52.5" +name = "windows_x86_64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] -name = "windows_x86_64_gnu" +name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] -name = "windows_x86_64_gnu" -version = "0.52.5" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.5" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags 2.9.1", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index 40e1a7a60fddc..3e400552cc07d 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" authors = ["Microsoft "] build = "build.rs" @@ -9,7 +9,7 @@ byteorder = "1.4.3" crc = "3.0.1" slog = "2.7.0" slog-async = "2.7.0" -slog-term = "2.9.1" +slog-term = "2.9.2" tempfile = "3.5.0" [target.'cfg(windows)'.dependencies.windows-sys] diff --git a/build/win32/code.iss b/build/win32/code.iss index f7091b28e5597..53016d814ae3a 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -86,7 +86,7 @@ Type: files; Name: "{app}\updating_version" Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1 Name: "addcontextmenufiles"; Description: "{cm:AddContextMenuFiles,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked -Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked; Check: not ShouldUseWindows11ContextMenu +Name: "addcontextmenufolders"; Description: "{cm:AddContextMenuFolders,{#NameShort}}"; GroupDescription: "{cm:Other}"; Flags: unchecked Name: "associatewithfiles"; Description: "{cm:AssociateWithFiles,{#NameShort}}"; GroupDescription: "{cm:Other}" Name: "addtopath"; Description: "{cm:AddToPath}"; GroupDescription: "{cm:Other}" Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{cm:Other}"; Check: WizardSilent @@ -95,9 +95,12 @@ Name: "runcode"; Description: "{cm:RunAfter,{#NameShort}}"; GroupDescription: "{ Name: "{app}"; AfterInstall: DisableAppDirInheritance [Files] -Source: "*"; Excludes: "\CodeSignSummary*.md,\tools,\tools\*,\policies,\policies\*,\appx,\appx\*,\resources\app\product.json,\{#ExeBasename}.exe,\{#ExeBasename}.VisualElementsManifest.xml,\bin,\bin\*"; DestDir: "{code:GetDestDir}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "*"; Excludes: "\CodeSignSummary*.md,\tools,\tools\*,\policies,\policies\*,\appx,\appx\*,\resources\app\product.json,\{#ExeBasename}.exe,{#ifdef ProxyExeBasename}\{#ProxyExeBasename}.exe,{#endif}\{#ExeBasename}.VisualElementsManifest.xml,\bin,\bin\*"; DestDir: "{code:GetDestDir}"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#ExeBasename}.exe"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetExeBasename}"; Flags: ignoreversion Source: "{#ExeBasename}.VisualElementsManifest.xml"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetVisualElementsManifest}"; Flags: ignoreversion +#ifdef ProxyExeBasename +Source: "{#ProxyExeBasename}.exe"; DestDir: "{code:GetDestDir}"; DestName: "{code:GetProxyExeBasename}"; Flags: ignoreversion +#endif Source: "tools\*"; DestDir: "{app}\{#VersionedResourcesFolder}\tools"; Flags: ignoreversion Source: "policies\*"; DestDir: "{code:GetDestDir}\{#VersionedResourcesFolder}\policies"; Flags: ignoreversion skipifsourcedoesntexist Source: "bin\{#TunnelApplicationName}.exe"; DestDir: "{code:GetDestDir}\bin"; DestName: "{code:GetBinDirTunnelApplicationFilename}"; Flags: ignoreversion skipifsourcedoesntexist @@ -113,6 +116,11 @@ Source: "appx\{#AppxPackageDll}"; DestDir: "{code:GetDestDir}\{#VersionedResourc Name: "{group}\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; AppUserModelID: "{#AppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{group}\{#NameLong}.lnk')) Name: "{autodesktop}\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; Tasks: desktopicon; AppUserModelID: "{#AppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{autodesktop}\{#NameLong}.lnk')) Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#NameLong}"; Filename: "{app}\{#ExeBasename}.exe"; Tasks: quicklaunchicon; AppUserModelID: "{#AppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#NameLong}.lnk')) +#ifdef ProxyExeBasename +Name: "{group}\{#ProxyExeBasename}"; Filename: "{app}\{#ProxyExeBasename}.exe"; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{group}\{#ProxyExeBasename}.lnk')) +Name: "{autodesktop}\{#ProxyNameLong}"; Filename: "{app}\{#ProxyExeBasename}.exe"; Tasks: desktopicon; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{autodesktop}\{#ProxyNameLong}.lnk')) +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#ProxyNameLong}"; Filename: "{app}\{#ProxyExeBasename}.exe"; Tasks: quicklaunchicon; AppUserModelID: "{#ProxyAppUserId}"; Check: ShouldUpdateShortcut(ExpandConstant('{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#ProxyNameLong}.lnk')) +#endif [Run] Filename: "{app}\{#ExeBasename}.exe"; Description: "{cm:LaunchProgram,{#NameLong}}"; Tasks: runcode; Flags: nowait postinstall; Check: ShouldRunAfterUpdate @@ -1276,15 +1284,24 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\{#RegValueName}Contex Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufiles; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\*\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%1"""; Tasks: addcontextmenufiles; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Tasks: addcontextmenufolders; Flags: uninsdeletekey; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu -Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Tasks: addcontextmenufolders; Check: not ShouldUseWindows11ContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\directory\background\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: ""; ValueData: "{cm:OpenWithCodeContextMenu,{#ShellNameShort}}"; Flags: uninsdeletekey; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu +Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu + +; URL Protocol handler for proxy executable +#ifdef ProxyExeBasename +#ifdef ProxyExeUrlProtocol +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: ""; ValueData: "URL:{#ProxyExeUrlProtocol}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#ProxyExeBasename}.exe"" --open-url -- ""%1"""; Flags: uninsdeletekey +#endif +#endif ; Environment #if "user" == InstallTarget @@ -1519,6 +1536,68 @@ begin Result := IsWindows11OrLater() and not IsWindows10ContextMenuForced(); end; +function HasLegacyFileContextMenu(): Boolean; +begin + Result := RegKeyExists({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}\command'); +end; + +function HasLegacyFolderContextMenu(): Boolean; +begin + Result := RegKeyExists({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}\command'); +end; + +function ShouldRepairFolderContextMenu(): Boolean; +begin + // Repair folder context menu during updates if: + // 1. This is a background update (not a fresh install or manual re-install) + // 2. Windows 11+ with forced classic context menu + // 3. Legacy file context menu exists (user previously selected it) + // 4. Legacy folder context menu is MISSING + Result := IsBackgroundUpdate() + and IsWindows11OrLater() + and IsWindows10ContextMenuForced() + and HasLegacyFileContextMenu() + and not HasLegacyFolderContextMenu(); +end; + +function ShouldInstallLegacyFolderContextMenu(): Boolean; +begin + Result := (WizardIsTaskSelected('addcontextmenufolders') and not ShouldUseWindows11ContextMenu()) or ShouldRepairFolderContextMenu(); +end; + +function BoolToStr(Value: Boolean): String; +begin + if Value then + Result := 'true' + else + Result := 'false'; +end; + +procedure LogContextMenuInstallState(); +begin + Log( + 'Context menu state: ' + + 'isBackgroundUpdate=' + BoolToStr(IsBackgroundUpdate()) + + ', isWindows11OrLater=' + BoolToStr(IsWindows11OrLater()) + + ', isWindows10ContextMenuForced=' + BoolToStr(IsWindows10ContextMenuForced()) + + ', shouldUseWindows11ContextMenu=' + BoolToStr(ShouldUseWindows11ContextMenu()) + + ', hasLegacyFileContextMenu=' + BoolToStr(HasLegacyFileContextMenu()) + + ', hasLegacyFolderContextMenu=' + BoolToStr(HasLegacyFolderContextMenu()) + + ', shouldRepairFolderContextMenu=' + BoolToStr(ShouldRepairFolderContextMenu()) + + ', shouldInstallLegacyFolderContextMenu=' + BoolToStr(ShouldInstallLegacyFolderContextMenu()) + + ', addcontextmenufiles=' + BoolToStr(WizardIsTaskSelected('addcontextmenufiles')) + + ', addcontextmenufolders=' + BoolToStr(WizardIsTaskSelected('addcontextmenufolders')) + ); +end; + +procedure DeleteLegacyContextMenuRegistryKeys(); +begin + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); + RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\Drive\shell\{#RegValueName}'); +end; + function GetAppMutex(Value: string): string; begin if IsBackgroundUpdate() then @@ -1562,6 +1641,16 @@ begin Result := ExpandConstant('{#ExeBasename}.exe'); end; +#ifdef ProxyExeBasename +function GetProxyExeBasename(Value: string): string; +begin + if IsBackgroundUpdate() and IsVersionedUpdate() then + Result := ExpandConstant('new_{#ProxyExeBasename}.exe') + else + Result := ExpandConstant('{#ProxyExeBasename}.exe'); +end; +#endif + function GetBinDirTunnelApplicationFilename(Value: string): string; begin if IsBackgroundUpdate() and IsVersionedUpdate() then @@ -1586,14 +1675,6 @@ begin Result := ExpandConstant('{#ApplicationName}.cmd'); end; -function BoolToStr(Value: Boolean): String; -begin - if Value then - Result := 'true' - else - Result := 'false'; -end; - function QualityIsInsiders(): boolean; begin if '{#Quality}' = 'insider' then @@ -1616,30 +1697,43 @@ end; function AppxPackageInstalled(const name: String; var ResultCode: Integer): Boolean; begin AppxPackageFullname := ''; + ResultCode := -1; try Log('Get-AppxPackage for package with name: ' + name); ExecAndLogOutput('powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Get-AppxPackage -Name ''' + name + ''' | Select-Object -ExpandProperty PackageFullName'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, @ExecAndGetFirstLineLog); except Log(GetExceptionMessage); + ResultCode := -1; end; if (AppxPackageFullname <> '') then Result := True else - Result := False + Result := False; + + Log('Get-AppxPackage result: name=' + name + ', installed=' + BoolToStr(Result) + ', resultCode=' + IntToStr(ResultCode) + ', packageFullName=' + AppxPackageFullname); end; procedure AddAppxPackage(); var AddAppxPackageResultCode: Integer; + IsCurrentAppxInstalled: Boolean; begin - if not SessionEndFileExists() and not AppxPackageInstalled(ExpandConstant('{#AppxPackageName}'), AddAppxPackageResultCode) then begin + if SessionEndFileExists() then begin + Log('Skipping Add-AppxPackage because session end was detected.'); + exit; + end; + + IsCurrentAppxInstalled := AppxPackageInstalled(ExpandConstant('{#AppxPackageName}'), AddAppxPackageResultCode); + if not IsCurrentAppxInstalled then begin Log('Installing appx ' + AppxPackageFullname + ' ...'); #if "user" == InstallTarget ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Path ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #else ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Add-AppxPackage -Stage ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''' -ExternalLocation ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx') + '''; Add-AppxProvisionedPackage -Online -SkipLicense -PackagePath ''' + ExpandConstant('{app}\{#VersionedResourcesFolder}\appx\{#AppxPackage}') + ''''), '', SW_HIDE, ewWaitUntilTerminated, AddAppxPackageResultCode); #endif - Log('Add-AppxPackage complete.'); + Log('Add-AppxPackage complete with result code ' + IntToStr(AddAppxPackageResultCode) + '.'); + end else begin + Log('Skipping Add-AppxPackage because package is already installed.'); end; end; @@ -1652,6 +1746,7 @@ begin if QualityIsInsiders() and not SessionEndFileExists() and AppxPackageInstalled('Microsoft.VSCodeInsiders', RemoveAppxPackageResultCode) then begin Log('Deleting old appx ' + AppxPackageFullname + ' installation...'); ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('Remove-AppxPackage -Package ''' + AppxPackageFullname + ''''), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); + Log('Remove-AppxPackage for old appx completed with result code ' + IntToStr(RemoveAppxPackageResultCode) + '.'); DeleteFile(ExpandConstant('{app}\appx\code_insiders_explorer_{#Arch}.appx')); DeleteFile(ExpandConstant('{app}\appx\code_insiders_explorer_command.dll')); end; @@ -1662,7 +1757,9 @@ begin #else ShellExec('', 'powershell.exe', '-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command ' + AddQuotes('$packages = Get-AppxPackage ''' + ExpandConstant('{#AppxPackageName}') + '''; foreach ($package in $packages) { Remove-AppxProvisionedPackage -PackageName $package.PackageFullName -Online }; foreach ($package in $packages) { Remove-AppxPackage -Package $package.PackageFullName -AllUsers }'), '', SW_HIDE, ewWaitUntilTerminated, RemoveAppxPackageResultCode); #endif - Log('Remove-AppxPackage for current appx installation complete.'); + Log('Remove-AppxPackage for current appx installation complete with result code ' + IntToStr(RemoveAppxPackageResultCode) + '.'); + end else if not SessionEndFileExists() then begin + Log('Skipping Remove-AppxPackage for current appx because package is not installed.'); end; end; #endif @@ -1674,6 +1771,8 @@ var begin if CurStep = ssPostInstall then begin + LogContextMenuInstallState(); + #ifdef AppxPackageName // Remove the appx package when user has forced Windows 10 context menus via // registry. This handles the case where the user previously had the appx @@ -1683,10 +1782,7 @@ begin end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\Drive\shell\{#RegValueName}'); + DeleteLegacyContextMenuRegistryKeys(); end; #endif @@ -1799,6 +1895,7 @@ begin if not CurUninstallStep = usUninstall then begin exit; end; + #ifdef AppxPackageName RemoveAppxPackage(); #endif diff --git a/build/win32/inno_updater.exe b/build/win32/inno_updater.exe index c3c4a0cd2bcb8..424e997bde5b9 100644 Binary files a/build/win32/inno_updater.exe and b/build/win32/inno_updater.exe differ diff --git a/cglicenses.json b/cglicenses.json index 2b1bc6fece5b6..2793b7fe2d6be 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -209,21 +209,6 @@ "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." ] }, - { - // Reason: Missing license file - "name": "readable-web-to-node-stream", - "fullLicenseText": [ - "(The MIT License)", - "", - "Copyright (c) 2019 Borewit", - "", - "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:", - "", - "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.", - "", - "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." - ] - }, { // Reason: The substack org has been deleted on GH "name": "concat-map", @@ -306,11 +291,6 @@ "name": "russh-keys", "fullLicenseTextUri": "https://raw.githubusercontent.com/warp-tech/russh/1da80d0d599b6ee2d257c544c0d6af4f649c9029/LICENSE-2.0.txt" }, - { - // Reason: license is in a subdirectory in repo - "name": "dirs-next", - "fullLicenseTextUri": "https://raw.githubusercontent.com/xdg-rs/dirs/af4aa39daba0ac68e222962a5aca17360158b7cc/dirs/LICENSE-MIT" - }, { // Reason: license is in a subdirectory in repo "name": "openssl", @@ -361,10 +341,6 @@ "name": "toml_datetime", "fullLicenseTextUri": "https://raw.githubusercontent.com/toml-rs/toml/main/crates/toml_datetime/LICENSE-MIT" }, - { // License is MIT/Apache and tool doesn't look in subfolders - "name": "dirs-sys-next", - "fullLicenseTextUri": "https://raw.githubusercontent.com/xdg-rs/dirs/master/dirs-sys/LICENSE-MIT" - }, { // License is MIT/Apache and gitlab API doesn't find the project "name": "libredox", "fullLicenseTextUri": "https://gitlab.redox-os.org/redox-os/libredox/-/raw/master/LICENSE" @@ -707,64 +683,6 @@ "For more information, please refer to " ] }, - { - "name": "@isaacs/balanced-match", - "fullLicenseText": [ - "MIT License", - "", - "Copyright Isaac Z. Schlueter ", - "", - "Original code Copyright Julian Gruber ", - "", - "Port to TypeScript Copyright Isaac Z. Schlueter ", - "", - "Permission is hereby granted, free of charge, to any person obtaining a copy of", - "this software and associated documentation files (the \"Software\"), to deal in", - "the Software without restriction, including without limitation the rights to", - "use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies", - "of the Software, and to permit persons to whom the Software is furnished to do", - "so, subject to the following conditions:", - "", - "The above copyright notice and this permission notice shall be included in all", - "copies or substantial portions of the Software.", - "", - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", - "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", - "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", - "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", - "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", - "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", - "SOFTWARE.", - "" - ] - }, - { - "name": "@isaacs/brace-expansion", - "fullLicenseText": [ - "MIT License", - "", - "Copyright (c) 2013 Julian Gruber ", - "", - "Permission is hereby granted, free of charge, to any person obtaining a copy", - "of this software and associated documentation files (the \"Software\"), to deal", - "in the Software without restriction, including without limitation the rights", - "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell", - "copies of the Software, and to permit persons to whom the Software is", - "furnished to do so, subject to the following conditions:", - "", - "The above copyright notice and this permission notice shall be included in all", - "copies or substantial portions of the Software.", - "", - "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR", - "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,", - "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE", - "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER", - "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,", - "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE", - "SOFTWARE.", - "" - ] - }, { // Reason: License file starts with (MIT) before the copyright, tool can't parse it "name": "balanced-match", @@ -817,5 +735,64 @@ // Reason: mono-repo "name": "@jridgewell/trace-mapping", "fullLicenseTextUri": "https://raw.githubusercontent.com/jridgewell/sourcemaps/refs/heads/main/packages/trace-mapping/LICENSE" + }, + { + // Reason: License text from https://github.com/github/copilot-cli/blob/master/LICENSE.md + // does not include a copyright statement. + "name": "@github/copilot", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-darwin-arm64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-darwin-x64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-linux-arm64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-linux-x64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-win32-arm64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + "name": "@github/copilot-win32-x64", + "prependLicenseText": [ + "Copyright (c) GitHub, Inc." + ] + }, + { + // Reason: NPM package does not include repository URL + "name": "@vscode/fs-copyfile", + "fullLicenseText": [ + "Copyright (c) Microsoft Corporation.", + "", + "MIT License", + "", + "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:", + "", + "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.", + "", + "THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." + ] } ] diff --git a/cgmanifest.json b/cgmanifest.json index 21554434500a7..a617dd1c78c7c 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -516,12 +516,12 @@ "git": { "name": "nodejs", "repositoryUrl": "https://github.com/nodejs/node", - "commitHash": "6add85e4c46b8be383c8b637102d6b6fd206adce", - "tag": "22.22.0" + "commitHash": "b4acf0c9393e4b31c4937564f059c672967161d8", + "tag": "22.22.1" } }, "isOnlyProductionDependency": true, - "version": "22.22.0" + "version": "22.22.1" }, { "component": { @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "a229dbf7a56336b847b34dfff1bac79afc311eee", - "tag": "39.6.0" + "commitHash": "e6928c13198c854aa014c319d72eea599e2e0ee7", + "tag": "39.8.3" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.6.0" + "version": "39.8.3" }, { "component": { @@ -606,7 +606,7 @@ } }, "license": "MIT and Creative Commons Attribution 4.0", - "version": "0.0.41" + "version": "0.0.46-0" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index cd9b8de6afba6..e50f85de23aa3 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -447,6 +447,7 @@ dependencies = [ "uuid", "winapi", "winreg 0.50.0", + "winresource", "zbus", "zip", ] @@ -2645,6 +2646,15 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2855,9 +2865,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -3004,12 +3014,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -3017,10 +3051,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.13.0", - "toml_datetime", - "winnow", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.14", ] +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower-service" version = "0.3.3" @@ -3699,6 +3748,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "winreg" version = "0.8.0" @@ -3718,6 +3773,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winresource" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 423224e10c55e..6f54ec61cbb58 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -53,7 +53,7 @@ cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" -tar = "0.4.38" +tar = "0.4.45" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 61cacaea79978..39091fdc1dbab 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -722,9 +722,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -1666,7 +1666,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [//]: # (crates) [`aead`]: ./aead -[`async‑signature`]: ./signature/async [`cipher`]: ./cipher [`crypto‑common`]: ./crypto-common [`crypto`]: ./crypto @@ -1828,7 +1827,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [//]: # (crates) [`aead`]: ./aead -[`async‑signature`]: ./signature/async [`cipher`]: ./cipher [`crypto‑common`]: ./crypto-common [`crypto`]: ./crypto @@ -1924,9 +1922,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5289,9 +5287,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5330,9 +5328,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5371,9 +5369,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5412,9 +5410,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5453,9 +5451,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5494,9 +5492,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5535,9 +5533,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5576,9 +5574,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5617,9 +5615,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5658,9 +5656,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5699,9 +5697,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5740,9 +5738,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5781,9 +5779,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -5822,9 +5820,9 @@ Furthermore, the crates are (usually automatically) derived from Apple SDKs, and that may have implications for licensing, see below for details. [#23]: https://github.com/madsmtm/objc2/issues/23 -[MIT]: https://opensource.org/license/MIT -[Zlib]: https://zlib.net/zlib_license.html -[Apache-2.0]: https://www.apache.org/licenses/LICENSE-2.0 +[MIT]: ./LICENSE-MIT.txt +[Zlib]: ./LICENSE-ZLIB.txt +[Apache-2.0]: ./LICENSE-APACHE.txt ## Apple SDKs @@ -9189,6 +9187,32 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- +serde_spanned 1.0.4 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + serde_urlencoded 0.7.1 - MIT/Apache-2.0 https://github.com/nox/serde_urlencoded @@ -10113,7 +10137,7 @@ DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -tar 0.4.44 - MIT OR Apache-2.0 +tar 0.4.45 - MIT OR Apache-2.0 https://github.com/alexcrichton/tar-rs Copyright (c) The tar-rs Project Contributors @@ -10517,7 +10541,34 @@ SOFTWARE. --------------------------------------------------------- +toml 0.9.12+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + toml_datetime 0.6.11 - MIT OR Apache-2.0 +toml_datetime 0.7.5+spec-1.1.0 - MIT OR Apache-2.0 https://github.com/toml-rs/toml ../../LICENSE-MIT @@ -10533,6 +10584,58 @@ https://github.com/toml-rs/toml --------------------------------------------------------- +toml_parser 1.0.9+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + +toml_writer 1.0.6+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + tower-service 0.3.3 - MIT https://github.com/tower-rs/tower @@ -12700,6 +12803,7 @@ MIT License --------------------------------------------------------- winnow 0.5.40 - MIT +winnow 0.7.14 - MIT https://github.com/winnow-rs/winnow The MIT License (MIT) @@ -12755,6 +12859,40 @@ THE SOFTWARE. --------------------------------------------------------- +winresource 0.1.30 - MIT +https://github.com/BenjaminRi/winresource + +The MIT License (MIT) + +Copyright 2016 Max Resch + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + wit-bindgen 0.51.0 - Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT https://github.com/bytecodealliance/wit-bindgen @@ -13531,33 +13669,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a zbus 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13565,33 +13677,7 @@ DEALINGS IN THE SOFTWARE. zbus_macros 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13599,33 +13685,7 @@ DEALINGS IN THE SOFTWARE. zbus_names 2.6.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14029,9 +14089,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Some files in the "tests/data" subdirectory of this repository are under other -licences; see files named LICENSE.*.txt for details. --------------------------------------------------------- --------------------------------------------------------- @@ -14071,33 +14128,7 @@ DEALINGS IN THE SOFTWARE. zvariant 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14105,33 +14136,7 @@ DEALINGS IN THE SOFTWARE. zvariant_derive 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14139,31 +14144,5 @@ DEALINGS IN THE SOFTWARE. zvariant_utils 1.0.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- \ No newline at end of file diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs index b73d0aa885b04..6c301ca9502be 100644 --- a/cli/src/bin/code/main.rs +++ b/cli/src/bin/code/main.rs @@ -8,7 +8,7 @@ use std::process::Command; use clap::Parser; use cli::{ - commands::{args, serve_web, tunnels, update, version, CommandContext}, + commands::{agent_host, args, serve_web, tunnels, update, version, CommandContext}, constants::get_default_user_agent, desktop, log, state::LauncherPaths, @@ -103,6 +103,10 @@ async fn main() -> Result<(), std::convert::Infallible> { serve_web::serve_web(context!(), sw_args).await } + Some(args::Commands::AgentHost(ah_args)) => { + agent_host::agent_host(context!(), ah_args).await + } + Some(args::Commands::Tunnel(mut tunnel_args)) => match tunnel_args.subcommand.take() { Some(args::TunnelSubcommand::Prune) => tunnels::prune(context!()).await, Some(args::TunnelSubcommand::Unregister) => tunnels::unregister(context!()).await, diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 027716947a37b..1b706653c6e04 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -5,6 +5,7 @@ mod context; +pub mod agent_host; pub mod args; pub mod serve_web; pub mod tunnels; diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs new file mode 100644 index 0000000000000..a5291281ba06e --- /dev/null +++ b/cli/src/commands/agent_host.rs @@ -0,0 +1,738 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::convert::Infallible; +use std::fs; +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Request, Response, Server}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::Mutex; + +use crate::async_pipe::{get_socket_name, get_socket_rw_stream, AsyncPipe}; +use crate::constants::VSCODE_CLI_QUALITY; +use crate::download_cache::DownloadCache; +use crate::log; +use crate::options::Quality; +use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; +use crate::tunnels::shutdown_signal::ShutdownRequest; +use crate::update_service::{ + unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, +}; +use crate::util::command::new_script_command; +use crate::util::errors::AnyError; +use crate::util::http::{self, ReqwestSimpleHttp}; +use crate::util::io::SilentCopyProgress; +use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; +use crate::{ + tunnels::legal, + util::{errors::CodeError, prereqs::PreReqChecker}, +}; + +use super::{args::AgentHostArgs, CommandContext}; + +/// How often to check for server updates. +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +/// How often to re-check whether the server has exited when an update is pending. +const UPDATE_POLL_INTERVAL: Duration = Duration::from_secs(10 * 60); +/// How long to wait for the server to signal readiness. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs a local agent host server. Downloads the latest VS Code server on +/// demand, starts it with `--enable-remote-auto-shutdown`, and proxies +/// WebSocket connections from a local TCP port to the server's agent host +/// socket. The server auto-shuts down when idle; the CLI checks for updates +/// in the background and starts the latest version on the next connection. +pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result { + legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; + + let platform: Platform = PreReqChecker::new().verify().await?; + + if !args.without_connection_token { + if let Some(p) = args.connection_token_file.as_deref() { + let token = fs::read_to_string(PathBuf::from(p)) + .map_err(CodeError::CouldNotReadConnectionTokenFile)?; + args.connection_token = Some(token.trim().to_string()); + } else { + let token_path = ctx.paths.root().join("agent-host-token"); + let token = mint_connection_token(&token_path, args.connection_token.clone()) + .map_err(CodeError::CouldNotCreateConnectionTokenFile)?; + args.connection_token = Some(token); + args.connection_token_file = Some(token_path.to_string_lossy().to_string()); + } + } + + let manager = AgentHostManager::new(&ctx, platform, args.clone())?; + + // Eagerly resolve the latest version so the first connection is fast. + // Skip when using a dev override since updates don't apply. + if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() { + match manager.get_latest_release().await { + Ok(release) => { + if let Err(e) = manager.ensure_downloaded(&release).await { + warning!(ctx.log, "Error downloading latest server version: {}", e); + } + } + Err(e) => warning!(ctx.log, "Error resolving initial server version: {}", e), + } + + // Start background update checker + let manager_for_updates = manager.clone(); + tokio::spawn(async move { + manager_for_updates.run_update_loop().await; + }); + } + + // Bind the HTTP/WebSocket proxy + let mut shutdown = ShutdownRequest::create_rx([ShutdownRequest::CtrlC]); + + let addr: SocketAddr = match &args.host { + Some(h) => SocketAddr::new(h.parse().map_err(CodeError::InvalidHostAddress)?, args.port), + None => SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port), + }; + let builder = Server::try_bind(&addr).map_err(CodeError::CouldNotListenOnInterface)?; + let bound_addr = builder.local_addr(); + + let mut url = format!("ws://{bound_addr}"); + if let Some(ct) = &args.connection_token { + url.push_str(&format!("?tkn={ct}")); + } + ctx.log + .result(format!("Agent host proxy listening on {url}")); + + let manager_for_svc = manager.clone(); + let make_svc = move || { + let mgr = manager_for_svc.clone(); + let service = service_fn(move |req| { + let mgr = mgr.clone(); + async move { handle_request(mgr, req).await } + }); + async move { Ok::<_, Infallible>(service) } + }; + + let server_future = builder + .serve(make_service_fn(|_| make_svc())) + .with_graceful_shutdown(async { + let _ = shutdown.wait().await; + }); + + let r = server_future.await; + manager.kill_running_server().await; + r.map_err(CodeError::CouldNotListenOnInterface)?; + + Ok(0) +} + +// ---- AgentHostManager ------------------------------------------------------- + +/// State of the running VS Code server process. +struct RunningServer { + child: tokio::process::Child, + commit: String, +} + +/// Manages the VS Code server lifecycle: on-demand start, auto-restart +/// after idle shutdown, and background update checking. +struct AgentHostManager { + log: log::Logger, + args: AgentHostArgs, + platform: Platform, + cache: DownloadCache, + update_service: UpdateService, + /// The latest known release, with the time it was checked. + latest_release: Mutex>, + /// The currently running server, if any. + running: Mutex>, + /// Barrier that opens when a server is ready (socket path available). + /// Reset each time a new server is started. + ready: Mutex>>>, +} + +impl AgentHostManager { + fn new( + ctx: &CommandContext, + platform: Platform, + args: AgentHostArgs, + ) -> Result, CodeError> { + // Seed latest_release from cache if available + let cache = ctx.paths.server_cache.clone(); + Ok(Arc::new(Self { + log: ctx.log.clone(), + args, + platform, + cache, + update_service: UpdateService::new( + ctx.log.clone(), + Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), + ), + latest_release: Mutex::new(None), + running: Mutex::new(None), + ready: Mutex::new(None), + })) + } + + /// Returns the socket path to a running server, starting one if needed. + async fn ensure_server(self: &Arc) -> Result { + // Fast path: if we already have a barrier, wait on it + { + let ready = self.ready.lock().await; + if let Some(barrier) = &*ready { + if barrier.is_open() { + // Check if the process is still running + let running = self.running.lock().await; + if running.is_some() { + return barrier + .clone() + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError); + } + } else { + // Still starting up, wait for it + let mut barrier = barrier.clone(); + drop(ready); + return barrier + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError); + } + } + } + + // Need to start a new server + self.start_server().await + } + + /// Starts the server with the latest already-downloaded version. + /// Only blocks on a network fetch if no version has been downloaded yet. + async fn start_server(self: &Arc) -> Result { + let (release, server_dir) = self.get_cached_or_download().await?; + + let (mut barrier, opener) = new_barrier::>(); + { + let mut ready = self.ready.lock().await; + *ready = Some(barrier.clone()); + } + + let self_clone = self.clone(); + let release_clone = release.clone(); + tokio::spawn(async move { + self_clone + .run_server(release_clone, server_dir, opener) + .await; + }); + + barrier + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError) + } + + /// Runs the server process to completion, handling readiness signaling. + async fn run_server( + self: &Arc, + release: Release, + server_dir: PathBuf, + opener: BarrierOpener>, + ) { + let executable = if let Some(p) = option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH") { + PathBuf::from(p) + } else { + server_dir + .join(SERVER_FOLDER_NAME) + .join("bin") + .join(release.quality.server_entrypoint()) + }; + + let agent_host_socket = get_socket_name(); + let mut cmd = new_script_command(&executable); + cmd.stdin(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.arg("--socket-path"); + cmd.arg(get_socket_name()); + cmd.arg("--agent-host-path"); + cmd.arg(&agent_host_socket); + cmd.args([ + "--start-server", + "--accept-server-license-terms", + "--enable-remote-auto-shutdown", + ]); + + if let Some(a) = &self.args.server_data_dir { + cmd.arg("--server-data-dir"); + cmd.arg(a); + } + if self.args.without_connection_token { + cmd.arg("--without-connection-token"); + } + if let Some(ct) = &self.args.connection_token_file { + cmd.arg("--connection-token-file"); + cmd.arg(ct); + } + cmd.env_remove("VSCODE_DEV"); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + opener.open(Err(e.to_string())); + return; + } + }; + + let commit_prefix = &release.commit[..release.commit.len().min(7)]; + let (mut stdout, mut stderr) = ( + BufReader::new(child.stdout.take().unwrap()).lines(), + BufReader::new(child.stderr.take().unwrap()).lines(), + ); + + // Wait for readiness with a timeout + let mut opener = Some(opener); + let socket_path = agent_host_socket.clone(); + let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT); + tokio::pin!(startup_deadline); + + let mut ready = false; + loop { + tokio::select! { + Ok(Some(l)) = stdout.next_line() => { + debug!(self.log, "[{} stdout]: {}", commit_prefix, l); + if !ready && l.contains("Agent host server listening on") { + ready = true; + if let Some(o) = opener.take() { + o.open(Ok(socket_path.clone())); + } + } + } + Ok(Some(l)) = stderr.next_line() => { + debug!(self.log, "[{} stderr]: {}", commit_prefix, l); + } + _ = &mut startup_deadline, if !ready => { + warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs()); + // Don't fail — the server may still start up, just slowly + if let Some(o) = opener.take() { + o.open(Ok(socket_path.clone())); + } + ready = true; + } + e = child.wait() => { + info!(self.log, "[{} process]: exited: {:?}", commit_prefix, e); + if let Some(o) = opener.take() { + o.open(Err(format!("Server exited before ready: {e:?}"))); + } + break; + } + } + + if ready { + break; + } + } + + // Store the running server state + { + let mut running = self.running.lock().await; + *running = Some(RunningServer { + child, + commit: release.commit.clone(), + }); + } + + if !ready { + return; + } + + info!(self.log, "[{}]: Server ready", commit_prefix); + + // Continue reading output until the process exits + let log = self.log.clone(); + let commit_prefix = commit_prefix.to_string(); + let self_clone = self.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + Ok(Some(l)) = stdout.next_line() => { + debug!(log, "[{} stdout]: {}", commit_prefix, l); + } + Ok(Some(l)) = stderr.next_line() => { + debug!(log, "[{} stderr]: {}", commit_prefix, l); + } + else => break, + } + } + + // Server process has exited (auto-shutdown or crash) + info!(log, "[{}]: Server process ended", commit_prefix); + let mut running = self_clone.running.lock().await; + if let Some(r) = &*running { + if r.commit == commit_prefix || r.commit.starts_with(&commit_prefix) { + // Only clear if it's still our server + } + } + *running = None; + }); + } + + /// Returns a release and its local directory. Prefers the latest known + /// release if it has already been downloaded; otherwise falls back to any + /// cached version. Only fetches from the network and downloads if + /// nothing is cached at all. + async fn get_cached_or_download(&self) -> Result<(Release, PathBuf), CodeError> { + // When using a dev override, skip the update service entirely - + // the override path is used directly by run_server(). + if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_some() { + let release = Release { + name: String::new(), + commit: String::from("dev"), + platform: self.platform, + target: TargetKind::Server, + quality: Quality::Insiders, + }; + return Ok((release, PathBuf::new())); + } + + // Best case: the latest known release is already downloaded + if let Some((_, release)) = &*self.latest_release.lock().await { + let name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&name) { + return Ok((release.clone(), dir)); + } + } + + let quality = VSCODE_CLI_QUALITY + .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .and_then(|q| { + Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) + })?; + + // Fall back to any cached version (still instant, just not the newest). + // Cache entries are named "-" via get_server_folder_name. + for entry in self.cache.get() { + if let Some(dir) = self.cache.exists(&entry) { + let (entry_quality, commit) = match entry.split_once('-') { + Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { + Ok(parsed) => (parsed, c.to_string()), + Err(_) => (quality, entry.clone()), + }, + None => (quality, entry.clone()), + }; + let release = Release { + name: String::new(), + commit, + platform: self.platform, + target: TargetKind::Server, + quality: entry_quality, + }; + return Ok((release, dir)); + } + } + + // Nothing cached — must fetch and download (blocks the first connection) + info!(self.log, "No cached server version, downloading latest..."); + let release = self.get_latest_release().await?; + let dir = self.ensure_downloaded(&release).await?; + Ok((release, dir)) + } + + /// Ensures the release is downloaded, returning the server directory. + async fn ensure_downloaded(&self, release: &Release) -> Result { + let cache_name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&cache_name) { + return Ok(dir); + } + + info!(self.log, "Downloading server {}", release.commit); + let release = release.clone(); + let log = self.log.clone(); + let update_service = self.update_service.clone(); + self.cache + .create(&cache_name, |target_dir| async move { + let tmpdir = tempfile::tempdir().unwrap(); + let response = update_service.get_download_stream(&release).await?; + let name = response.url_path_basename().unwrap(); + let archive_path = tmpdir.path().join(name); + http::download_into_file( + &archive_path, + log.get_download_logger("Downloading server:"), + response, + ) + .await?; + let server_dir = target_dir.join(SERVER_FOLDER_NAME); + unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; + Ok(()) + }) + .await + .map_err(|e| CodeError::ServerDownloadError(e.to_string())) + } + + /// Gets the latest release, caching the result. + async fn get_latest_release(&self) -> Result { + let mut latest = self.latest_release.lock().await; + let now = Instant::now(); + + let quality = VSCODE_CLI_QUALITY + .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .and_then(|q| { + Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) + })?; + + let result = self + .update_service + .get_latest_commit(self.platform, TargetKind::Server, quality) + .await + .map_err(|e| CodeError::UpdateCheckFailed(e.to_string())); + + // If the update service is unavailable, fall back to the cached version + if let (Err(e), Some((_, previous))) = (&result, latest.clone()) { + warning!(self.log, "Error checking for updates, using cached: {}", e); + *latest = Some((now, previous.clone())); + return Ok(previous); + } + + let release = result?; + debug!(self.log, "Resolved server version: {}", release); + *latest = Some((now, release.clone())); + Ok(release) + } + + /// Background loop: checks for updates periodically and pre-downloads + /// new versions when the server is idle. + async fn run_update_loop(self: Arc) { + let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL); + interval.tick().await; // skip the immediate first tick + + loop { + interval.tick().await; + + let new_release = match self.get_latest_release().await { + Ok(r) => r, + Err(e) => { + warning!(self.log, "Update check failed: {}", e); + continue; + } + }; + + // Check if we already have this version + let name = get_server_folder_name(new_release.quality, &new_release.commit); + if self.cache.exists(&name).is_some() { + continue; + } + + info!(self.log, "New server version available: {}", new_release); + + // Wait until the server is not running before downloading + loop { + { + let running = self.running.lock().await; + if running.is_none() { + break; + } + } + debug!(self.log, "Server still running, waiting before updating..."); + tokio::time::sleep(UPDATE_POLL_INTERVAL).await; + } + + // Download the new version + match self.ensure_downloaded(&new_release).await { + Ok(_) => info!(self.log, "Updated server to {}", new_release), + Err(e) => warning!(self.log, "Failed to download update: {}", e), + } + } + } + + /// Kills the currently running server, if any. + async fn kill_running_server(&self) { + let mut running = self.running.lock().await; + if let Some(mut server) = running.take() { + let _ = server.child.kill().await; + } + } +} + +// ---- HTTP/WebSocket proxy --------------------------------------------------- + +/// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket. +async fn handle_request( + manager: Arc, + req: Request, +) -> Result, Infallible> { + let socket_path = match manager.ensure_server().await { + Ok(p) => p, + Err(e) => { + error!(manager.log, "Error starting agent host: {:?}", e); + return Ok(Response::builder() + .status(503) + .body(Body::from(format!("Error starting agent host: {e:?}"))) + .unwrap()); + } + }; + + let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE); + + let rw = match get_socket_rw_stream(&socket_path).await { + Ok(rw) => rw, + Err(e) => { + error!( + manager.log, + "Error connecting to agent host socket: {:?}", e + ); + return Ok(Response::builder() + .status(503) + .body(Body::from(format!("Error connecting to agent host: {e:?}"))) + .unwrap()); + } + }; + + if is_upgrade { + Ok(forward_ws_to_server(rw, req).await) + } else { + Ok(forward_http_to_server(rw, req).await) + } +} + +/// Proxies a standard HTTP request through the socket. +async fn forward_http_to_server(rw: AsyncPipe, req: Request) -> Response { + let (mut request_sender, connection) = + match hyper::client::conn::Builder::new().handshake(rw).await { + Ok(r) => r, + Err(e) => return connection_err(e), + }; + + tokio::spawn(connection); + + request_sender + .send_request(req) + .await + .unwrap_or_else(connection_err) +} + +/// Proxies a WebSocket upgrade request through the socket. +async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request) -> Response { + let (mut request_sender, connection) = + match hyper::client::conn::Builder::new().handshake(rw).await { + Ok(r) => r, + Err(e) => return connection_err(e), + }; + + tokio::spawn(connection); + + let mut proxied_req = Request::builder().uri(req.uri()); + for (k, v) in req.headers() { + proxied_req = proxied_req.header(k, v); + } + + let mut res = request_sender + .send_request(proxied_req.body(Body::empty()).unwrap()) + .await + .unwrap_or_else(connection_err); + + let mut proxied_res = Response::new(Body::empty()); + *proxied_res.status_mut() = res.status(); + for (k, v) in res.headers() { + proxied_res.headers_mut().insert(k, v.clone()); + } + + if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS { + tokio::spawn(async move { + let (s_req, s_res) = + tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res)); + + if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) { + let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await; + } + }); + } + + proxied_res +} + +fn connection_err(err: hyper::Error) -> Response { + Response::builder() + .status(503) + .body(Body::from(format!( + "Error connecting to agent host: {err:?}" + ))) + .unwrap() +} + +fn mint_connection_token(path: &Path, prefer_token: Option) -> std::io::Result { + #[cfg(not(windows))] + use std::os::unix::fs::OpenOptionsExt; + + let mut f = fs::OpenOptions::new(); + f.create(true); + f.write(true); + f.read(true); + #[cfg(not(windows))] + f.mode(0o600); + let mut f = f.open(path)?; + + if prefer_token.is_none() { + let mut t = String::new(); + f.read_to_string(&mut t)?; + let t = t.trim(); + if !t.is_empty() { + return Ok(t.to_string()); + } + } + + f.set_len(0)?; + let prefer_token = prefer_token.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + f.write_all(prefer_token.as_bytes())?; + Ok(prefer_token) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn mint_connection_token_generates_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + + // First call with no preference generates a UUID and persists it + let token1 = mint_connection_token(&path, None).unwrap(); + assert!(!token1.is_empty()); + assert_eq!(fs::read_to_string(&path).unwrap(), token1); + + // Second call with no preference reads the existing token + let token2 = mint_connection_token(&path, None).unwrap(); + assert_eq!(token1, token2); + } + + #[test] + fn mint_connection_token_respects_preferred() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + + // Providing a preferred token writes it to the file + let token = mint_connection_token(&path, Some("my-token".to_string())).unwrap(); + assert_eq!(token, "my-token"); + assert_eq!(fs::read_to_string(&path).unwrap(), "my-token"); + } + + #[test] + fn mint_connection_token_preferred_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + + mint_connection_token(&path, None).unwrap(); + + // Providing a preference overwrites any existing token + let token = mint_connection_token(&path, Some("override".to_string())).unwrap(); + assert_eq!(token, "override"); + assert_eq!(fs::read_to_string(&path).unwrap(), "override"); + } +} diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 6301bdd3104e5..9211cdc38d43c 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -185,6 +185,10 @@ pub enum Commands { /// Runs the control server on process stdin/stdout #[clap(hide = true)] CommandShell(CommandShellArgs), + + /// Runs a local agent host server. + #[clap(name = "agent-host")] + AgentHost(AgentHostArgs), } #[derive(Args, Debug, Clone)] @@ -221,6 +225,31 @@ pub struct ServeWebArgs { pub commit_id: Option, } +#[derive(Args, Debug, Clone)] +pub struct AgentHostArgs { + /// Host to listen on, defaults to 'localhost' + #[clap(long)] + pub host: Option, + /// Port to listen on. If 0 is passed a random free port is picked. + #[clap(long, default_value_t = 0)] + pub port: u16, + /// A secret that must be included with all requests. + #[clap(long)] + pub connection_token: Option, + /// A file containing a secret that must be included with all requests. + #[clap(long)] + pub connection_token_file: Option, + /// Run without a connection token. Only use this if the connection is secured by other means. + #[clap(long)] + pub without_connection_token: bool, + /// If set, the user accepts the server license terms and the server will be started without a user prompt. + #[clap(long)] + pub accept_server_license_terms: bool, + /// Specifies the directory that server data is kept in. + #[clap(long)] + pub server_data_dir: Option, +} + #[derive(Args, Debug, Clone)] pub struct CommandShellArgs { #[clap(flatten)] diff --git a/eslint.config.js b/eslint.config.js index 93a8a1b7b4396..26943ddee990a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,7 @@ export default tseslint.config( 'local/code-no-localized-model-description': 'warn', 'local/code-policy-localization-key-match': 'warn', 'local/code-no-localization-template-literals': 'error', + 'local/code-no-icons-in-localized-strings': 'warn', 'local/code-no-http-import': ['warn', { target: 'src/vs/**' }], 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ @@ -183,6 +184,18 @@ export default tseslint.config( ] } }, + // Disallow common telemetry properties in event data + { + files: [ + 'src/**/*.ts', + ], + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-telemetry-common-property': 'warn', + } + }, // Disallow 'in' operator except in type predicates { files: [ @@ -322,6 +335,7 @@ export default tseslint.config( 'src/vs/workbench/services/remote/common/tunnelModel.ts', 'src/vs/workbench/services/search/common/textSearchManager.ts', 'src/vs/workbench/test/browser/workbenchTestServices.ts', + 'src/vs/platform/agentHost/common/state/protocol/reducers.ts', 'test/automation/src/playwrightDriver.ts', '.eslint-plugin-local/**/*', ], @@ -828,6 +842,36 @@ export default tseslint.config( ] } }, + // git extension - ban non-type imports from git.d.ts (use git.constants for runtime values) + { + files: [ + 'extensions/git/src/**/*.ts', + ], + ignores: [ + 'extensions/git/src/api/git.constants.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + '@typescript-eslint': tseslint.plugin, + }, + rules: { + 'no-restricted-imports': 'off', + '@typescript-eslint/no-restricted-imports': [ + 'warn', + { + 'patterns': [ + { + 'group': ['*/api/git'], + 'allowTypeImports': true, + 'message': 'Use \'import type\' for types from git.d.ts and import runtime const enum values from git.constants instead' + }, + ] + } + ] + } + }, // vscode API { files: [ @@ -1012,6 +1056,33 @@ export default tseslint.config( ] } }, + // electron-main layer: prevent static imports of heavy node_modules + // that would be synchronously loaded on startup + { + files: [ + 'src/vs/code/electron-main/**/*.ts', + 'src/vs/code/node/**/*.ts', + 'src/vs/platform/*/electron-main/**/*.ts', + 'src/vs/platform/*/node/**/*.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-static-node-module-import': [ + 'error', + // Files that run in separate processes, not on the electron-main startup path + 'src/vs/platform/agentHost/node/copilot/**/*.ts', + 'src/vs/platform/files/node/watcher/**/*.ts', + 'src/vs/platform/terminal/node/**/*.ts', + // Files that use small, safe modules + 'src/vs/platform/environment/node/argv.ts', + ] + } + }, // browser/electron-browser layer { files: [ @@ -1426,6 +1497,7 @@ export default tseslint.config( // - electron-main 'when': 'hasNode', 'allow': [ + '@github/copilot-sdk', '@parcel/watcher', '@vscode/sqlite3', '@vscode/vscode-languagedetection', @@ -1468,6 +1540,7 @@ export default tseslint.config( 'vscode-regexpp', 'vscode-textmate', 'worker_threads', + 'ws', '@xterm/addon-clipboard', '@xterm/addon-image', '@xterm/addon-ligatures', @@ -1917,11 +1990,14 @@ export default tseslint.config( 'vs/editor/~', 'vs/editor/contrib/*/~', 'vs/editor/editor.all.js', + 'vs/sessions/~', + 'vs/sessions/services/*/~', + 'vs/sessions/contrib/*/~', 'vs/workbench/~', 'vs/workbench/api/~', 'vs/workbench/services/*/~', 'vs/workbench/contrib/*/~', - 'vs/workbench/contrib/terminal/terminal.all.js' + 'vs/workbench/contrib/terminal/terminal.all.js', ] }, { @@ -1935,6 +2011,27 @@ export default tseslint.config( 'vs/editor/contrib/*/~', 'vs/editor/editor.all.js', 'vs/sessions/~', + 'vs/sessions/services/*/~', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/api/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/sessions.common.main.js' + ] + }, + { + 'target': 'src/vs/sessions/sessions.web.main.ts', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/editor/editor.all.js', + 'vs/sessions/~', + 'vs/sessions/services/*/~', 'vs/sessions/contrib/*/~', 'vs/workbench/~', 'vs/workbench/api/~', @@ -1943,6 +2040,55 @@ export default tseslint.config( 'vs/sessions/sessions.common.main.js' ] }, + { + 'target': 'src/vs/sessions/sessions.web.main.internal.ts', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/sessions/~', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/sessions.web.main.js' + ] + }, + { + 'target': 'src/vs/sessions/test/sessions.web.test.internal.ts', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/sessions/~', + 'vs/sessions/test/**', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/sessions.web.main.js' + ] + }, + { + 'target': 'src/vs/sessions/test/{web.test.ts,web.test.factory.ts}', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/sessions/~', + 'vs/sessions/test/**', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~' + ] + }, { 'target': 'src/vs/sessions/~', 'restrictions': [ @@ -1955,7 +2101,8 @@ export default tseslint.config( 'vs/workbench/browser/**', 'vs/workbench/contrib/**', 'vs/workbench/services/*/~', - 'vs/sessions/~' + 'vs/sessions/~', + 'vs/sessions/services/*/~' ] }, { @@ -1974,6 +2121,32 @@ export default tseslint.config( 'vs/sessions/contrib/*/~' ] }, + { + 'target': 'src/vs/sessions/services/*/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/services/*/~', + 'vs/sessions/~', + 'vs/sessions/services/*/~', + { + 'when': 'test', + 'pattern': 'vs/workbench/contrib/*/~' + }, // TODO@layers + 'tas-client', // node module allowed even in /common/ + 'vscode-textmate', // node module allowed even in /common/ + '@vscode/vscode-languagedetection', // node module allowed even in /common/ + '@vscode/tree-sitter-wasm', // type import + { + 'when': 'hasBrowser', + 'pattern': '@xterm/xterm' + } // node module allowed even in /browser/ + ] + }, ] } }, @@ -2147,21 +2320,13 @@ export default tseslint.config( '@typescript-eslint': tseslint.plugin, }, rules: { - '@typescript-eslint/naming-convention': [ + 'no-restricted-syntax': [ 'warn', { - 'selector': 'default', - 'modifiers': ['private'], - 'format': null, - 'leadingUnderscore': 'require' + selector: ':matches(PropertyDefinition, TSParameterProperty, MethodDefinition[key.name!="constructor"])[accessibility="private"]', + message: 'Use #private instead', }, - { - 'selector': 'default', - 'modifiers': ['public'], - 'format': null, - 'leadingUnderscore': 'forbid' - } - ] + ], } }, // Additional extension strictness rules @@ -2225,7 +2390,10 @@ export default tseslint.config( 'selector': `NewExpression[callee.object.name='Intl']`, 'message': 'Use safeIntl helper instead for safe and lazy use of potentially expensive Intl methods.' }, + { + 'selector': 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.type="TSQualifiedName"][typeAnnotation.typeName.left.type="Identifier"][typeAnnotation.typeName.left.name="sinon"][typeAnnotation.typeName.right.name="SinonStub"]', + 'message': `Avoid casting with 'as sinon.SinonStub'. Prefer typed stubs from 'sinon.stub(...)' or capture the stub in a typed variable.` + }, ], } - }, -); + }); diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 0000000000000..cfaf6b0ca8dc1 --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Built-In Extensions + +This directory contains built-in extensions that ship with VS Code. + +## Basic Structure + +A typical TypeScript-based built-in extension has the following structure: + +- `package.json`: extension manifest. +- `src/`: Main directory for TypeScript source code. +- `tsconfig.json`: primary TypeScript config. This should inherit from `tsconfig.base.json`. +- `esbuild.mts`: esbuild build script used for production builds. +- `.vscodeignore`: Ignore file list. You can copy this from an existing extension. + +TypeScript-based extensions have the following output structure: + +- `out`: Output directory for development builds +- `dist`: Output directory for production builds. + + +## Enabling an Extension in the Browser + +By default extensions will only target desktop. To enable an extension in browsers as well: + +- Add a `"browser"` entry in `package.json` pointing to the browser bundle (for example `"./dist/browser/extension"`). +- Add `tsconfig.browser.json` that typechecks only browser-safe sources. +- Add an `esbuild.browser.mts` file. This should set `platform: 'browser'`. + +Make sure the browser build of the extension only uses browser-safe APIs. If an extension needs different behavior between desktop and web, you can create distinct entrypoints for each target: + +- `src/extension.ts`: Desktop entrypoint. +- `src/extension.browser.ts`: Browser entrypoint. Make sure `esbuild.browser.mts` builds this and that `tsconfig.browser.json` targets it. diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index 231eda54dba77..e5cf7c26d199e 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -51,9 +51,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index 1fd31eeae793b..0abce3a2cfb48 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -218,7 +218,7 @@ }, "scope": "resource", "default": [], - "description": "%css.lint.validProperties.desc%" + "markdownDescription": "%css.lint.validProperties.desc%" }, "css.lint.ieHack": { "type": "string", @@ -534,7 +534,7 @@ }, "scope": "resource", "default": [], - "description": "%scss.lint.validProperties.desc%" + "markdownDescription": "%scss.lint.validProperties.desc%" }, "scss.lint.ieHack": { "type": "string", @@ -840,7 +840,7 @@ }, "scope": "resource", "default": [], - "description": "%less.lint.validProperties.desc%" + "markdownDescription": "%less.lint.validProperties.desc%" }, "less.lint.ieHack": { "type": "string", diff --git a/extensions/css-language-features/package.nls.json b/extensions/css-language-features/package.nls.json index 057ec214bc2f8..d3de22412c286 100644 --- a/extensions/css-language-features/package.nls.json +++ b/extensions/css-language-features/package.nls.json @@ -33,7 +33,7 @@ "css.format.enable.desc": "Enable/disable default CSS formatter.", "css.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "css.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "css.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "css.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "css.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "css.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "css.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#css.format.preserveNewLines#` is enabled.", @@ -67,7 +67,7 @@ "less.format.enable.desc": "Enable/disable default LESS formatter.", "less.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "less.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "less.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "less.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "less.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "less.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "less.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#less.format.preserveNewLines#` is enabled.", @@ -101,7 +101,7 @@ "scss.format.enable.desc": "Enable/disable default SCSS formatter.", "scss.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "scss.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "scss.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "scss.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "scss.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "scss.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "scss.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#scss.format.preserveNewLines#` is enabled.", diff --git a/extensions/css/cgmanifest.json b/extensions/css/cgmanifest.json index 7b85089b6b91a..93bd8ba0f3109 100644 --- a/extensions/css/cgmanifest.json +++ b/extensions/css/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "microsoft/vscode-css", "repositoryUrl": "https://github.com/microsoft/vscode-css", - "commitHash": "a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887" + "commitHash": "9a07d76cb0e7a56f9bfc76328a57227751e4adb4" } }, "licenseDetail": [ diff --git a/extensions/css/syntaxes/css.tmLanguage.json b/extensions/css/syntaxes/css.tmLanguage.json index 5ba8bc90b7381..484af027c195c 100644 --- a/extensions/css/syntaxes/css.tmLanguage.json +++ b/extensions/css/syntaxes/css.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/microsoft/vscode-css/commit/a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887", + "version": "https://github.com/microsoft/vscode-css/commit/9a07d76cb0e7a56f9bfc76328a57227751e4adb4", "name": "CSS", "scopeName": "source.css", "patterns": [ @@ -1401,7 +1401,7 @@ "property-keywords": { "patterns": [ { - "match": "(?xi) (? un interface RunConfig { readonly platform: 'node' | 'browser'; + readonly format?: 'cjs' | 'esm'; readonly srcDir: string; readonly outdir: string; readonly entryPoints: string[] | Record | { in: string; out: string }[]; @@ -48,6 +49,7 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { sourcemap: true, target: ['es2024'], external: ['vscode'], + format: config.format ?? 'cjs', entryPoints: config.entryPoints, outdir, logOverride: { @@ -57,10 +59,8 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { }; if (config.platform === 'node') { - options.format = 'cjs'; options.mainFields = ['module', 'main']; } else if (config.platform === 'browser') { - options.format = 'cjs'; options.mainFields = ['browser', 'module', 'main']; options.alias = { 'path': 'path-browserify', diff --git a/extensions/extension-editing/esbuild.browser.mts b/extensions/extension-editing/esbuild.browser.mts index 170f3cda31380..58b5fb7d6d5fa 100644 --- a/extensions/extension-editing/esbuild.browser.mts +++ b/extensions/extension-editing/esbuild.browser.mts @@ -15,4 +15,7 @@ run({ }, srcDir, outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, }, process.argv); diff --git a/extensions/extension-editing/package-lock.json b/extensions/extension-editing/package-lock.json index be1aa96eea6cb..d96f9a2bccacf 100644 --- a/extensions/extension-editing/package-lock.json +++ b/extensions/extension-editing/package-lock.json @@ -14,18 +14,37 @@ "parse5": "^3.0.2" }, "devDependencies": { - "@types/markdown-it": "0.0.2", + "@types/markdown-it": "^14", "@types/node": "22.x" }, "engines": { "vscode": "^1.4.0" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-0.0.2.tgz", - "integrity": "sha1-XZrRnm5lCM3S8llt+G/Qqt5ZhmA= sha512-A2seE+zJYSjGHy7L/v0EN/xRfgv2A60TuXOwI8tt5aZxF4UeoYIkM2jERnNH8w4VFr7oFEm0lElGOao7fZgygQ==", - "dev": true + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { "version": "22.13.10", diff --git a/extensions/extension-editing/package.json b/extensions/extension-editing/package.json index 3e277dbbfd385..c491fbedca2f5 100644 --- a/extensions/extension-editing/package.json +++ b/extensions/extension-editing/package.json @@ -66,7 +66,7 @@ ] }, "devDependencies": { - "@types/markdown-it": "0.0.2", + "@types/markdown-it": "^14", "@types/node": "22.x" }, "repository": { diff --git a/extensions/extension-editing/src/extensionEditingBrowserMain.ts b/extensions/extension-editing/src/extensionEditingBrowserMain.ts index f9d6885c6223c..57c969d017020 100644 --- a/extensions/extension-editing/src/extensionEditingBrowserMain.ts +++ b/extensions/extension-editing/src/extensionEditingBrowserMain.ts @@ -5,11 +5,14 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; export function activate(context: vscode.ExtensionContext) { //package.json suggestions context.subscriptions.push(registerPackageDocumentCompletions()); + //package.json go to definition for NLS strings + context.subscriptions.push(new PackageDocumentL10nSupport()); } function registerPackageDocumentCompletions(): vscode.Disposable { @@ -18,5 +21,4 @@ function registerPackageDocumentCompletions(): vscode.Disposable { return new PackageDocument(document).provideCompletionItems(position, token); } }); - } diff --git a/extensions/extension-editing/src/extensionEditingMain.ts b/extensions/extension-editing/src/extensionEditingMain.ts index c056fbfa975ae..c620b3039541f 100644 --- a/extensions/extension-editing/src/extensionEditingMain.ts +++ b/extensions/extension-editing/src/extensionEditingMain.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; import { ExtensionLinter } from './extensionLinter'; export function activate(context: vscode.ExtensionContext) { @@ -15,6 +16,9 @@ export function activate(context: vscode.ExtensionContext) { //package.json code actions for lint warnings context.subscriptions.push(registerCodeActionsProvider()); + // package.json l10n support + context.subscriptions.push(new PackageDocumentL10nSupport()); + context.subscriptions.push(new ExtensionLinter()); } diff --git a/extensions/extension-editing/src/extensionLinter.ts b/extensions/extension-editing/src/extensionLinter.ts index 5c73304b4d891..6249500e2d171 100644 --- a/extensions/extension-editing/src/extensionLinter.ts +++ b/extensions/extension-editing/src/extensionLinter.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { URL } from 'url'; import { parseTree, findNodeAtLocation, Node as JsonNode, getNodeValue } from 'jsonc-parser'; -import * as MarkdownItType from 'markdown-it'; +import type MarkdownIt from 'markdown-it'; import { commands, languages, workspace, Disposable, TextDocument, Uri, Diagnostic, Range, DiagnosticSeverity, Position, env, l10n } from 'vscode'; import { INormalizedVersion, normalizeVersion, parseVersion } from './extensionEngineValidation'; @@ -44,7 +44,7 @@ enum Context { } interface TokenAndPosition { - token: MarkdownItType.Token; + token: MarkdownIt.Token; begin: number; end: number; } @@ -67,7 +67,7 @@ export class ExtensionLinter { private packageJsonQ = new Set(); private readmeQ = new Set(); private timer: NodeJS.Timeout | undefined; - private markdownIt: MarkdownItType.MarkdownIt | undefined; + private markdownIt: MarkdownIt | undefined; private parse5: typeof import('parse5') | undefined; constructor() { @@ -292,7 +292,7 @@ export class ExtensionLinter { this.markdownIt = new ((await import('markdown-it')).default); } const tokens = this.markdownIt.parse(text, {}); - const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownItType.Token[], begin = 0, end = text.length): TokenAndPosition[] { + const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownIt.Token[], begin = 0, end = text.length): TokenAndPosition[] { const tokensAndPositions = tokens.map(token => { if (token.map) { const tokenBegin = document.offsetAt(new Position(token.map[0], 0)); @@ -313,7 +313,7 @@ export class ExtensionLinter { }); return tokensAndPositions.concat( ...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length) - .map(tnp => toTokensAndPositions.call(this, tnp.token.children, tnp.begin, tnp.end)) + .map(tnp => toTokensAndPositions.call(this, tnp.token.children ?? [], tnp.begin, tnp.end)) ); }).call(this, tokens); @@ -373,7 +373,7 @@ export class ExtensionLinter { } } - private locateToken(text: string, begin: number, end: number, token: MarkdownItType.Token, content: string | null) { + private locateToken(text: string, begin: number, end: number, token: MarkdownIt.Token, content: string | null) { if (content) { const tokenBegin = text.indexOf(content, begin); if (tokenBegin !== -1) { diff --git a/extensions/extension-editing/src/packageDocumentL10nSupport.ts b/extensions/extension-editing/src/packageDocumentL10nSupport.ts new file mode 100644 index 0000000000000..4d844e98d5f71 --- /dev/null +++ b/extensions/extension-editing/src/packageDocumentL10nSupport.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLocation, getNodeValue, parseTree, findNodeAtLocation, visit } from 'jsonc-parser'; + + +const packageJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.json' }; +const packageNlsJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.nls.json' }; + +export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vscode.ReferenceProvider, vscode.Disposable { + + private readonly _disposables: vscode.Disposable[] = []; + + constructor() { + this._disposables.push(vscode.languages.registerDefinitionProvider(packageJsonSelector, this)); + this._disposables.push(vscode.languages.registerDefinitionProvider(packageNlsJsonSelector, this)); + + this._disposables.push(vscode.languages.registerReferenceProvider(packageNlsJsonSelector, this)); + this._disposables.push(vscode.languages.registerReferenceProvider(packageJsonSelector, this)); + } + + dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + } + + public async provideDefinition(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise { + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.json') { + return this.provideNlsValueDefinition(document, position); + } + + if (basename === 'package.nls.json') { + return this.provideNlsKeyDefinition(document, position); + } + + return undefined; + } + + private async provideNlsValueDefinition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.resolveNlsDefinition(nlsRef, nlsUri); + } + + private async provideNlsKeyDefinition(nlsDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + return this.resolveNlsDefinition(nlsKey, nlsDoc.uri); + } + + private async resolveNlsDefinition(origin: { key: string; range: vscode.Range }, nlsUri: vscode.Uri): Promise { + const target = await this.findNlsKeyDeclaration(origin.key, nlsUri); + if (!target) { + return undefined; + } + + return [{ + originSelectionRange: origin.range, + targetUri: target.uri, + targetRange: target.range, + }]; + } + + private getNlsReferenceAtPosition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(packageJsonDoc.getText(), packageJsonDoc.offsetAt(position)); + if (!location.previousNode || location.previousNode.type !== 'string') { + return undefined; + } + + const value = getNodeValue(location.previousNode); + if (typeof value !== 'string') { + return undefined; + } + + const match = value.match(/^%(.+)%$/); + if (!match) { + return undefined; + } + + const nodeStart = packageJsonDoc.positionAt(location.previousNode.offset); + const nodeEnd = packageJsonDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key: match[1], range: new vscode.Range(nodeStart, nodeEnd) }; + } + + public async provideReferences(document: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext, _token: vscode.CancellationToken): Promise { + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.nls.json') { + return this.provideNlsKeyReferences(document, position, context); + } + if (basename === 'package.json') { + return this.provideNlsValueReferences(document, position, context); + } + return undefined; + } + + private async provideNlsKeyReferences(nlsDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + + const packageJsonUri = vscode.Uri.joinPath(nlsDoc.uri, '..', 'package.json'); + return this.findAllNlsReferences(nlsKey.key, packageJsonUri, nlsDoc.uri, context); + } + + private async provideNlsValueReferences(packageJsonDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.findAllNlsReferences(nlsRef.key, packageJsonDoc.uri, nlsUri, context); + } + + private async findAllNlsReferences(nlsKey: string, packageJsonUri: vscode.Uri, nlsUri: vscode.Uri, context: vscode.ReferenceContext): Promise { + const locations = await this.findNlsReferencesInPackageJson(nlsKey, packageJsonUri); + + if (context.includeDeclaration) { + const decl = await this.findNlsKeyDeclaration(nlsKey, nlsUri); + if (decl) { + locations.push(decl); + } + } + + return locations; + } + + private async findNlsKeyDeclaration(nlsKey: string, nlsUri: vscode.Uri): Promise { + try { + const nlsDoc = await vscode.workspace.openTextDocument(nlsUri); + const nlsTree = parseTree(nlsDoc.getText()); + if (!nlsTree) { + return undefined; + } + + const node = findNodeAtLocation(nlsTree, [nlsKey]); + if (!node?.parent) { + return undefined; + } + + const keyNode = node.parent.children?.[0]; + if (!keyNode) { + return undefined; + } + + const start = nlsDoc.positionAt(keyNode.offset); + const end = nlsDoc.positionAt(keyNode.offset + keyNode.length); + return new vscode.Location(nlsUri, new vscode.Range(start, end)); + } catch { + return undefined; + } + } + + private async findNlsReferencesInPackageJson(nlsKey: string, packageJsonUri: vscode.Uri): Promise { + let packageJsonDoc: vscode.TextDocument; + try { + packageJsonDoc = await vscode.workspace.openTextDocument(packageJsonUri); + } catch { + return []; + } + + const text = packageJsonDoc.getText(); + const needle = `%${nlsKey}%`; + const locations: vscode.Location[] = []; + + visit(text, { + onLiteralValue(value, offset, length) { + if (value === needle) { + const start = packageJsonDoc.positionAt(offset); + const end = packageJsonDoc.positionAt(offset + length); + locations.push(new vscode.Location(packageJsonUri, new vscode.Range(start, end))); + } + } + }); + + return locations; + } + + private getNlsKeyDefinitionAtPosition(nlsDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(nlsDoc.getText(), nlsDoc.offsetAt(position)); + + // Must be on a top-level property key + if (location.path.length !== 1 || !location.isAtPropertyKey || !location.previousNode) { + return undefined; + } + + const key = location.path[0] as string; + const start = nlsDoc.positionAt(location.previousNode.offset); + const end = nlsDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key, range: new vscode.Range(start, end) }; + } +} diff --git a/extensions/git/.vscodeignore b/extensions/git/.vscodeignore index a1fc5df7d26b8..9de840770944a 100644 --- a/extensions/git/.vscodeignore +++ b/extensions/git/.vscodeignore @@ -3,5 +3,5 @@ test/** out/** tsconfig*.json build/** -extension.webpack.config.js +esbuild*.mts package-lock.json diff --git a/extensions/git/esbuild.mts b/extensions/git/esbuild.mts new file mode 100644 index 0000000000000..5203712993577 --- /dev/null +++ b/extensions/git/esbuild.mts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +async function copyNonTsFiles(outDir: string): Promise { + const entries = await fs.readdir(srcDir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name.endsWith('.ts')) { + continue; + } + const srcPath = path.join(entry.parentPath, entry.name); + const relativePath = path.relative(srcDir, srcPath); + const destPath = path.join(outDir, relativePath); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + } +} + +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), + 'askpass-main': path.join(srcDir, 'askpass-main.ts'), + 'git-editor-main': path.join(srcDir, 'git-editor-main.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + external: ['vscode', '@vscode/fs-copyfile'], + }, +}, process.argv, copyNonTsFiles); diff --git a/extensions/git/package-lock.json b/extensions/git/package-lock.json index b552ce9fa5b3f..ceff06a20e8f6 100644 --- a/extensions/git/package-lock.json +++ b/extensions/git/package-lock.json @@ -11,8 +11,9 @@ "dependencies": { "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", + "@vscode/fs-copyfile": "2.0.0", "byline": "^5.0.0", - "file-type": "16.5.4", + "file-type": "21.3.2", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" @@ -28,6 +29,16 @@ "vscode": "^1.5.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@joaomoreno/unique-names-generator": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@joaomoreno/unique-names-generator/-/unique-names-generator-5.2.0.tgz", @@ -161,10 +172,28 @@ "integrity": "sha512-OUUJTh3fnaUSzg9DEHgv3d7jC+DnPL65mIO7RaR+jWve7+MmcgIvF79gY97DPQ4frH+IpNR78YAYd/dW4gK3kg==", "license": "MIT" }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" }, "node_modules/@types/byline": { "version": "4.2.31", @@ -218,6 +247,19 @@ "vscode": "^1.75.0" } }, + "node_modules/@vscode/fs-copyfile": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vscode/fs-copyfile/-/fs-copyfile-2.0.0.tgz", + "integrity": "sha512-ARb4+9rN905WjJtQ2mSBG/q4pjJkSRun/MkfCeRkk7h/5J8w4vd18NCePFJ/ZucIwXx/7mr9T6nz9Vtt1tk7hg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">=22.6.0" + } + }, "node_modules/byline": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", @@ -226,17 +268,36 @@ "node": ">=0.10.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/file-type": { - "version": "16.5.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", - "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", + "license": "MIT", "dependencies": { - "readable-web-to-node-stream": "^3.0.0", - "strtok3": "^6.2.4", - "token-types": "^4.1.1" + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -259,12 +320,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + ], + "license": "BSD-3-Clause" }, "node_modules/isexe": { "version": "3.1.1", @@ -274,17 +331,17 @@ "node": ">=16" } }, - "node_modules/peek-readable": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", - "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", - "engines": { - "node": ">=8" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" }, "node_modules/picomatch": { "version": "2.3.1", @@ -297,91 +354,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readable-web-to-node-stream": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", - "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", "dependencies": { - "readable-stream": "^3.6.0" + "@tokenizer/token": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strtok3": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", - "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", "dependencies": { + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", - "peek-readable": "^4.1.0" + "ieee754": "^1.2.1" }, "engines": { - "node": ">=10" + "node": ">=14.16" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/token-types": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.0.tgz", - "integrity": "sha512-P0rrp4wUpefLncNamWIef62J0v0kQR/GfDVji9WKY7GDCWy5YbVSrKUTam07iWPZQGy0zWNOfstYTykMmPNR7w==", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/undici-types": { @@ -391,11 +407,6 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, "node_modules/vscode-uri": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.0.0.tgz", diff --git a/extensions/git/package.json b/extensions/git/package.json index 39017ca4e1eed..723d36fe7ba43 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -38,6 +38,7 @@ "scmTextDocument", "scmValidation", "statusBarItemTooltip", + "taskRunOptions", "tabInputMultiDiff", "tabInputTextMerge", "textEditorDiffInformation", @@ -1147,6 +1148,46 @@ "title": "%command.deleteRef%", "category": "Git", "enablement": "!operationInProgress" + }, + { + "command": "git.repositories.worktreeCopyBranchName", + "title": "%command.artifactCopyBranchName%", + "category": "Git" + }, + { + "command": "git.repositories.worktreeCopyCommitHash", + "title": "%command.artifactCopyCommitHash%", + "category": "Git" + }, + { + "command": "git.repositories.worktreeCopyPath", + "title": "%command.artifactCopyWorktreePath%", + "category": "Git" + }, + { + "command": "git.repositories.copyCommitHash", + "title": "%command.artifactCopyCommitHash%", + "category": "Git" + }, + { + "command": "git.repositories.copyBranchName", + "title": "%command.artifactCopyBranchName%", + "category": "Git" + }, + { + "command": "git.repositories.copyTagName", + "title": "%command.artifactCopyTagName%", + "category": "Git" + }, + { + "command": "git.repositories.copyStashName", + "title": "%command.artifactCopyStashName%", + "category": "Git" + }, + { + "command": "git.repositories.stashCopyBranchName", + "title": "%command.artifactCopyBranchName%", + "category": "Git" } ], "continueEditSession": [ @@ -1846,6 +1887,38 @@ { "command": "git.repositories.deleteWorktree", "when": "false" + }, + { + "command": "git.repositories.worktreeCopyBranchName", + "when": "false" + }, + { + "command": "git.repositories.worktreeCopyCommitHash", + "when": "false" + }, + { + "command": "git.repositories.worktreeCopyPath", + "when": "false" + }, + { + "command": "git.repositories.copyCommitHash", + "when": "false" + }, + { + "command": "git.repositories.copyBranchName", + "when": "false" + }, + { + "command": "git.repositories.copyTagName", + "when": "false" + }, + { + "command": "git.repositories.copyStashName", + "when": "false" + }, + { + "command": "git.repositories.stashCopyBranchName", + "when": "false" } ], "scm/title": [ @@ -2090,6 +2163,16 @@ "group": "3_drop@3", "when": "scmProvider == git && scmArtifactGroupId == stashes" }, + { + "command": "git.repositories.stashCopyBranchName", + "group": "4_copy@1", + "when": "scmProvider == git && scmArtifactGroupId == stashes" + }, + { + "command": "git.repositories.copyStashName", + "group": "4_copy@2", + "when": "scmProvider == git && scmArtifactGroupId == stashes" + }, { "command": "git.repositories.checkout", "group": "1_checkout@1", @@ -2130,6 +2213,21 @@ "group": "4_compare@1", "when": "scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)" }, + { + "command": "git.repositories.copyCommitHash", + "group": "5_copy@2", + "when": "scmProvider == git && (scmArtifactGroupId == branches || scmArtifactGroupId == tags)" + }, + { + "command": "git.repositories.copyBranchName", + "group": "5_copy@1", + "when": "scmProvider == git && scmArtifactGroupId == branches" + }, + { + "command": "git.repositories.copyTagName", + "group": "5_copy@2", + "when": "scmProvider == git && scmArtifactGroupId == tags" + }, { "command": "git.repositories.openWorktreeInNewWindow", "group": "inline@1", @@ -2149,6 +2247,21 @@ "command": "git.repositories.deleteWorktree", "group": "2_modify@1", "when": "scmProvider == git && scmArtifactGroupId == worktrees" + }, + { + "command": "git.repositories.worktreeCopyCommitHash", + "group": "3_copy@2", + "when": "scmProvider == git && scmArtifactGroupId == worktrees" + }, + { + "command": "git.repositories.worktreeCopyBranchName", + "group": "3_copy@1", + "when": "scmProvider == git && scmArtifactGroupId == worktrees" + }, + { + "command": "git.repositories.worktreeCopyPath", + "group": "3_copy@3", + "when": "scmProvider == git && scmArtifactGroupId == worktrees" } ], "scm/resourceGroup/context": [ @@ -4234,8 +4347,9 @@ "dependencies": { "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", + "@vscode/fs-copyfile": "2.0.0", "byline": "^5.0.0", - "file-type": "16.5.4", + "file-type": "21.3.2", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 9d469e33c84e9..147a75f9b7024 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -129,7 +129,7 @@ "command.stashView": "View Stash...", "command.stashView2": "View Stash", "command.timelineOpenDiff": "Open Changes", - "command.timelineCopyCommitId": "Copy Commit ID", + "command.timelineCopyCommitId": "Copy Commit Hash", "command.timelineCopyCommitMessage": "Copy Commit Message", "command.timelineSelectForCompare": "Select for Compare", "command.timelineCompareWithSelected": "Compare with Selected", @@ -148,6 +148,11 @@ "command.graphCompareWithMergeBase": "Compare with Merge Base", "command.graphCompareWithRemote": "Compare with Remote", "command.deleteRef": "Delete", + "command.artifactCopyCommitHash": "Copy Commit Hash", + "command.artifactCopyBranchName": "Copy Branch Name", + "command.artifactCopyTagName": "Copy Tag Name", + "command.artifactCopyStashName": "Copy Stash Name", + "command.artifactCopyWorktreePath": "Copy Worktree Path", "command.blameToggleEditorDecoration": "Toggle Git Blame Editor Decoration", "command.blameToggleStatusBarItem": "Toggle Git Blame Status Bar Item", "command.api.getRepositories": "Get Repositories", diff --git a/extensions/git/src/actionButton.ts b/extensions/git/src/actionButton.ts index 63eefb1de028a..5804c23f69755 100644 --- a/extensions/git/src/actionButton.ts +++ b/extensions/git/src/actionButton.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable, Event, EventEmitter, SourceControlActionButton, Uri, workspace, l10n, LogOutputChannel } from 'vscode'; -import { Branch, RefType, Status } from './api/git'; +import type { Branch } from './api/git'; +import { RefType, Status } from './api/git.constants'; import { OperationKind } from './operation'; import { CommitCommandsCenter } from './postCommitCommands'; import { Repository } from './repository'; diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 0791401665ec0..d003348045f79 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,7 +5,8 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, GitErrorCodes, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind, RepositoryAccessDetails } from './git'; +import type { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, Ref, Submodule, Commit, Change, RepositoryUIState, LogOptions, APIState, CommitOptions, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider, InitOptions, SourceControlHistoryItemDetailsProvider, CloneOptions, CommitShortStat, DiffChange, Worktree, RepositoryKind, RepositoryAccessDetails } from './git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './git.constants'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, filterEvent, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -211,6 +212,10 @@ export class ApiRepository implements Repository { return this.#repository.diffBetweenWithStats(ref1, ref2, path); } + diffBetweenWithStats2(ref: string, path?: string): Promise { + return this.#repository.diffBetweenWithStats2(ref, path); + } + hashObject(data: string): Promise { return this.#repository.hashObject(data); } @@ -319,6 +324,10 @@ export class ApiRepository implements Repository { return this.#repository.mergeAbort(); } + rebase(branch: string): Promise { + return this.#repository.rebase(branch); + } + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise { return this.#repository.createStash(options?.message, options?.includeUntracked, options?.staged); } @@ -346,6 +355,14 @@ export class ApiRepository implements Repository { migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { return this.#repository.migrateChanges(sourceRepositoryPath, options); } + + generateRandomBranchName(): Promise { + return this.#repository.generateRandomBranchName(); + } + + isBranchProtected(branch?: Branch): boolean { + return this.#repository.isBranchProtected(branch); + } } export class ApiGit implements Git { diff --git a/extensions/git/src/api/extension.ts b/extensions/git/src/api/extension.ts index 7b0313b6c26e6..a4c6af087ce1b 100644 --- a/extensions/git/src/api/extension.ts +++ b/extensions/git/src/api/extension.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Model } from '../model'; -import { GitExtension, Repository, API } from './git'; +import type { GitExtension, Repository, API } from './git'; import { ApiRepository, ApiImpl } from './api1'; import { Event, EventEmitter } from 'vscode'; import { CloneManager } from '../cloneManager'; diff --git a/extensions/git/src/api/git.constants.ts b/extensions/git/src/api/git.constants.ts new file mode 100644 index 0000000000000..5847e21d5d0da --- /dev/null +++ b/extensions/git/src/api/git.constants.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as git from './git'; + +export type ForcePushMode = git.ForcePushMode; +export type RefType = git.RefType; +export type Status = git.Status; +export type GitErrorCodes = git.GitErrorCodes; + +export const ForcePushMode = Object.freeze({ + Force: 0, + ForceWithLease: 1, + ForceWithLeaseIfIncludes: 2, +}) satisfies typeof git.ForcePushMode; + +export const RefType = Object.freeze({ + Head: 0, + RemoteHead: 1, + Tag: 2, +}) satisfies typeof git.RefType; + +export const Status = Object.freeze({ + INDEX_MODIFIED: 0, + INDEX_ADDED: 1, + INDEX_DELETED: 2, + INDEX_RENAMED: 3, + INDEX_COPIED: 4, + + MODIFIED: 5, + DELETED: 6, + UNTRACKED: 7, + IGNORED: 8, + INTENT_TO_ADD: 9, + INTENT_TO_RENAME: 10, + TYPE_CHANGED: 11, + + ADDED_BY_US: 12, + ADDED_BY_THEM: 13, + DELETED_BY_US: 14, + DELETED_BY_THEM: 15, + BOTH_ADDED: 16, + BOTH_DELETED: 17, + BOTH_MODIFIED: 18, +}) satisfies typeof git.Status; + +export const GitErrorCodes = Object.freeze({ + BadConfigFile: 'BadConfigFile', + BadRevision: 'BadRevision', + AuthenticationFailed: 'AuthenticationFailed', + NoUserNameConfigured: 'NoUserNameConfigured', + NoUserEmailConfigured: 'NoUserEmailConfigured', + NoRemoteRepositorySpecified: 'NoRemoteRepositorySpecified', + NotAGitRepository: 'NotAGitRepository', + NotASafeGitRepository: 'NotASafeGitRepository', + NotAtRepositoryRoot: 'NotAtRepositoryRoot', + Conflict: 'Conflict', + StashConflict: 'StashConflict', + UnmergedChanges: 'UnmergedChanges', + PushRejected: 'PushRejected', + ForcePushWithLeaseRejected: 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected: 'ForcePushWithLeaseIfIncludesRejected', + RemoteConnectionError: 'RemoteConnectionError', + DirtyWorkTree: 'DirtyWorkTree', + CantOpenResource: 'CantOpenResource', + GitNotFound: 'GitNotFound', + CantCreatePipe: 'CantCreatePipe', + PermissionDenied: 'PermissionDenied', + CantAccessRemote: 'CantAccessRemote', + RepositoryNotFound: 'RepositoryNotFound', + RepositoryIsLocked: 'RepositoryIsLocked', + BranchNotFullyMerged: 'BranchNotFullyMerged', + NoRemoteReference: 'NoRemoteReference', + InvalidBranchName: 'InvalidBranchName', + BranchAlreadyExists: 'BranchAlreadyExists', + NoLocalChanges: 'NoLocalChanges', + NoStashFound: 'NoStashFound', + LocalChangesOverwritten: 'LocalChangesOverwritten', + NoUpstreamBranch: 'NoUpstreamBranch', + IsInSubmodule: 'IsInSubmodule', + WrongCase: 'WrongCase', + CantLockRef: 'CantLockRef', + CantRebaseMultipleBranches: 'CantRebaseMultipleBranches', + PatchDoesNotApply: 'PatchDoesNotApply', + NoPathFound: 'NoPathFound', + UnknownPath: 'UnknownPath', + EmptyCommitMessage: 'EmptyCommitMessage', + BranchFastForwardRejected: 'BranchFastForwardRejected', + BranchNotYetBorn: 'BranchNotYetBorn', + TagConflict: 'TagConflict', + CherryPickEmpty: 'CherryPickEmpty', + CherryPickConflict: 'CherryPickConflict', + WorktreeContainsChanges: 'WorktreeContainsChanges', + WorktreeAlreadyExists: 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed: 'WorktreeBranchAlreadyUsed', +}) satisfies Record; diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 287dd4399bf2c..95da0f84c748e 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -278,6 +278,7 @@ export interface Repository { diffBetween(ref1: string, ref2: string, path: string): Promise; diffBetweenPatch(ref1: string, ref2: string, path?: string): Promise; diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats2(ref: string, path?: string): Promise; hashObject(data: string): Promise; @@ -315,6 +316,7 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; merge(ref: string): Promise; mergeAbort(): Promise; + rebase(branch: string): Promise; createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; applyStash(index?: number): Promise; @@ -325,6 +327,10 @@ export interface Repository { deleteWorktree(path: string, options?: { force?: boolean }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; + + generateRandomBranchName(): Promise; + + isBranchProtected(branch?: Branch): boolean; } export interface RemoteSource { diff --git a/extensions/git/src/artifactProvider.ts b/extensions/git/src/artifactProvider.ts index f63899efa3edb..f9e2d99087fd6 100644 --- a/extensions/git/src/artifactProvider.ts +++ b/extensions/git/src/artifactProvider.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode'; -import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util'; +import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktreeFolder } from './util'; import { Repository } from './repository'; -import { Ref, RefType, Worktree } from './api/git'; +import type { Ref, Worktree } from './api/git'; +import { RefType } from './api/git.constants'; import { OperationKind } from './operation'; /** @@ -177,7 +178,7 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp ]).join(' \u2022 '), icon: w.main ? new ThemeIcon('repo') - : isCopilotWorktree(w.path) + : isCopilotWorktreeFolder(w.path) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') })); diff --git a/extensions/git/src/askpass.ts b/extensions/git/src/askpass.ts index 1cb1890e24245..cc9e607f08f48 100644 --- a/extensions/git/src/askpass.ts +++ b/extensions/git/src/askpass.ts @@ -6,7 +6,7 @@ import { window, InputBoxOptions, Uri, Disposable, workspace, QuickPickOptions, l10n, LogOutputChannel } from 'vscode'; import { IDisposable, EmptyDisposable, toDisposable, extractFilePathFromArgs } from './util'; import { IIPCHandler, IIPCServer } from './ipc/ipcServer'; -import { CredentialsProvider, Credentials } from './api/git'; +import type { CredentialsProvider, Credentials } from './api/git'; import { ITerminalEnvironmentProvider } from './terminal'; import { AskpassPaths } from './askpassManager'; diff --git a/extensions/git/src/autofetch.ts b/extensions/git/src/autofetch.ts index 00d6450b3baf8..201bf647f1a11 100644 --- a/extensions/git/src/autofetch.ts +++ b/extensions/git/src/autofetch.ts @@ -6,7 +6,7 @@ import { workspace, Disposable, EventEmitter, Memento, window, MessageItem, ConfigurationTarget, Uri, ConfigurationChangeEvent, l10n, env } from 'vscode'; import { Repository } from './repository'; import { eventToPromise, filterEvent, onceEvent } from './util'; -import { GitErrorCodes } from './api/git'; +import { GitErrorCodes } from './api/git.constants'; export class AutoFetcher { diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 8773264eb70f2..83a60ec9e1879 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -13,7 +13,7 @@ import { fromGitUri, isGitUri, toGitUri } from './uri'; import { emojify, ensureEmojis } from './emoji'; import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -import { AvatarQuery, AvatarQueryCommit } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit } from './api/git'; import { LRUCache } from './cache'; import { AVATAR_SIZE, getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover'; diff --git a/extensions/git/src/branchProtection.ts b/extensions/git/src/branchProtection.ts index 0fbb3b7d4b166..b142a333b24a1 100644 --- a/extensions/git/src/branchProtection.ts +++ b/extensions/git/src/branchProtection.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event, EventEmitter, Uri, workspace } from 'vscode'; -import { BranchProtection, BranchProtectionProvider } from './api/git'; +import type { BranchProtection, BranchProtectionProvider } from './api/git'; import { dispose, filterEvent } from './util'; export interface IBranchProtectionProviderRegistry { diff --git a/extensions/git/src/cloneManager.ts b/extensions/git/src/cloneManager.ts index 49d57d8763c63..cee231dda779c 100644 --- a/extensions/git/src/cloneManager.ts +++ b/extensions/git/src/cloneManager.ts @@ -39,7 +39,8 @@ export class CloneManager { /* __GDPR__ "clone" : { "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, + "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } } */ this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_URL' }); @@ -74,7 +75,8 @@ export class CloneManager { /* __GDPR__ "clone" : { "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, + "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } } */ this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'no_directory' }); @@ -105,7 +107,8 @@ export class CloneManager { /* __GDPR__ "clone" : { "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, + "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } } */ this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'directory_not_empty' }); @@ -115,7 +118,8 @@ export class CloneManager { /* __GDPR__ "clone" : { "owner": "lszomoru", - "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" } + "outcome" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the git operation" }, + "openFolder": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Indicates whether the folder is opened following the clone operation" } } */ this.telemetryReporter.sendTelemetryEvent('clone', { outcome: 'error' }); diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index f1456675f2e61..51cbef08d2e68 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -7,8 +7,8 @@ import * as os from 'os'; import * as path from 'path'; import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, SourceControlArtifact, ProgressLocation } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; -import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; +import type { CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git'; import { Model } from './model'; import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository'; @@ -106,6 +106,8 @@ class RefItem implements QuickPickItem { return `refs/remotes/${this.ref.name}`; case RefType.Tag: return `refs/tags/${this.ref.name}`; + default: + throw new Error('Unknown ref type'); } } get refName(): string | undefined { return this.ref.name; } @@ -1028,8 +1030,8 @@ export class CommandCenter { } @command('git.clone') - async clone(url?: string, parentPath?: string, options?: { ref?: string }): Promise { - await this.cloneManager.clone(url, { parentPath, ...options }); + async clone(url?: string, parentPath?: string, options?: { ref?: string; postCloneAction?: 'none' }): Promise { + return this.cloneManager.clone(url, { parentPath, ...options }); } @command('git.cloneRecursive') @@ -1038,24 +1040,73 @@ export class CommandCenter { } @command('_git.cloneRepository') - async cloneRepository(url: string, parentPath: string): Promise { + async cloneRepository(url: string, localPath: string, ref?: string): Promise { const opts = { location: ProgressLocation.Notification, title: l10n.t('Cloning git repository "{0}"...', url), cancellable: true }; + const parentPath = path.dirname(localPath); + const targetName = path.basename(localPath); + await window.withProgress( opts, - (progress, token) => this.model.git.clone(url, { parentPath, progress }, token) + (progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token) ); } + @command('_git.checkout') + async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.checkout(treeish, [], detached ? { detached: true } : {}); + } + @command('_git.pull') - async pullRepository(repositoryPath: string): Promise { + async pullRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + return repo.pull(); + } + + @command('_git.fetchRepository') + async fetchRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.fetch(); + } + + @command('_git.revParse') + async revParse(repositoryPath: string, ref: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', ref]); + return result.stdout.trim(); + } + + @command('_git.revListCount') + async revListCount(repositoryPath: string, fromRef: string, toRef: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-list', '--count', `${fromRef}..${toRef}`]); + return Number(result.stdout.trim()) || 0; + } + + @command('_git.revParseAbbrevRef') + async revParseAbbrevRef(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', '--abbrev-ref', 'HEAD']); + return result.stdout.trim(); + } + + @command('_git.mergeBranch') + async mergeBranch(repositoryPath: string, branch: string): Promise { const dotGit = await this.git.getRepositoryDotGit(repositoryPath); const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); - await repo.pull(); + const result = await repo.exec(['merge', branch, '--no-edit']); + return result.stdout.trim(); } @command('git.init') @@ -2940,48 +2991,6 @@ export class CommandCenter { await this._branch(repository, undefined, true); } - private async generateRandomBranchName(repository: Repository, separator: string): Promise { - const config = workspace.getConfiguration('git'); - const branchRandomNameDictionary = config.get('branchRandomName.dictionary')!; - - const dictionaries: string[][] = []; - for (const dictionary of branchRandomNameDictionary) { - if (dictionary.toLowerCase() === 'adjectives') { - dictionaries.push(adjectives); - } - if (dictionary.toLowerCase() === 'animals') { - dictionaries.push(animals); - } - if (dictionary.toLowerCase() === 'colors') { - dictionaries.push(colors); - } - if (dictionary.toLowerCase() === 'numbers') { - dictionaries.push(NumberDictionary.generate({ length: 3 })); - } - } - - if (dictionaries.length === 0) { - return ''; - } - - // 5 attempts to generate a random branch name - for (let index = 0; index < 5; index++) { - const randomName = uniqueNamesGenerator({ - dictionaries, - length: dictionaries.length, - separator - }); - - // Check for local ref conflict - const refs = await repository.getRefs({ pattern: `refs/heads/${randomName}` }); - if (refs.length === 0) { - return randomName; - } - } - - return ''; - } - private async promptForBranchName(repository: Repository, defaultName?: string, initialValue?: string): Promise { const config = workspace.getConfiguration('git'); const branchPrefix = config.get('branchPrefix')!; @@ -2995,8 +3004,7 @@ export class CommandCenter { } const getBranchName = async (): Promise => { - const branchName = branchRandomNameEnabled ? await this.generateRandomBranchName(repository, branchWhitespaceChar) : ''; - return `${branchPrefix}${branchName}`; + return await repository.generateRandomBranchName() ?? branchPrefix; }; const getValueSelection = (value: string): [number, number] | undefined => { @@ -5415,6 +5423,97 @@ export class CommandCenter { await repository.deleteWorktree(artifact.id); } + @command('git.repositories.worktreeCopyBranchName', { repository: true }) + async artifactWorktreeCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const worktrees = await repository.getWorktreeDetails(); + const worktree = worktrees.find(w => w.path === artifact.id); + if (!worktree || worktree.detached) { + return; + } + + env.clipboard.writeText(worktree.ref.substring(11)); + } + + @command('git.repositories.worktreeCopyCommitHash', { repository: true }) + async artifactWorktreeCopyCommitHash(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const worktrees = await repository.getWorktreeDetails(); + const worktree = worktrees.find(w => w.path === artifact.id); + if (!worktree?.commitDetails) { + return; + } + + env.clipboard.writeText(worktree.commitDetails.hash); + } + + @command('git.repositories.worktreeCopyPath', { repository: true }) + async artifactWorktreeCopyPath(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.id); + } + + @command('git.repositories.copyCommitHash', { repository: true }) + async artifactCopyCommitHash(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + const commit = await repository.getCommit(artifact.id); + env.clipboard.writeText(commit.hash); + } + + @command('git.repositories.copyBranchName', { repository: true }) + async artifactCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.copyTagName', { repository: true }) + async artifactCopyTagName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.copyStashName', { repository: true }) + async artifactCopyStashName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact) { + return; + } + + env.clipboard.writeText(artifact.name); + } + + @command('git.repositories.stashCopyBranchName', { repository: true }) + async artifactStashCopyBranchName(repository: Repository, artifact: SourceControlArtifact): Promise { + if (!repository || !artifact?.description) { + return; + } + + const stashes = await repository.getStashes(); + const stash = stashes.find(s => artifact.id === `stash@{${s.index}}`); + if (!stash?.branchName) { + return; + } + + env.clipboard.writeText(stash.branchName); + } + private createCommand(id: string, key: string, method: Function, options: ScmCommandOptions): (...args: any[]) => any { const result = (...args: any[]) => { let result: Promise; @@ -5545,15 +5644,14 @@ export class CommandCenter { options.modal = false; break; default: { - const hint = (err.stderr || err.message || String(err)) + const hintLines = (err.stderr || err.stdout || err.message || String(err)) .replace(/^error: /mi, '') .replace(/^> husky.*$/mi, '') .split(/[\r\n]/) - .filter((line: string) => !!line) - [0]; + .filter((line: string) => !!line); - message = hint - ? l10n.t('Git: {0}', hint) + message = hintLines.length > 0 + ? l10n.t('Git: {0}', err.stdout ? hintLines[hintLines.length - 1] : hintLines[0]) : l10n.t('Git error'); break; diff --git a/extensions/git/src/decorationProvider.ts b/extensions/git/src/decorationProvider.ts index fb895d5aff2b3..11778f7f8f582 100644 --- a/extensions/git/src/decorationProvider.ts +++ b/extensions/git/src/decorationProvider.ts @@ -9,7 +9,8 @@ import { Repository, GitResourceGroup } from './repository'; import { Model } from './model'; import { debounce } from './decorators'; import { filterEvent, dispose, anyEvent, PromiseSource, combinedDisposable, runAndSubscribeEvent } from './util'; -import { Change, GitErrorCodes, Status } from './api/git'; +import type { Change } from './api/git'; +import { GitErrorCodes, Status } from './api/git.constants'; function equalSourceControlHistoryItemRefs(ref1?: SourceControlHistoryItemRef, ref2?: SourceControlHistoryItemRef): boolean { if (ref1 === ref2) { diff --git a/extensions/git/src/diagnostics.ts b/extensions/git/src/diagnostics.ts index a8c1a3deea3c5..64bf11076fe08 100644 --- a/extensions/git/src/diagnostics.ts +++ b/extensions/git/src/diagnostics.ts @@ -85,7 +85,11 @@ export class GitCommitInputBoxDiagnosticsManager { const threshold = index === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; if (line.text.length > threshold) { - const diagnostic = new Diagnostic(line.range, l10n.t('{0} characters over {1} in current line', line.text.length - threshold, threshold), this.severity); + const charactersOver = line.text.length - threshold; + const lineLengthMessage = charactersOver === 1 + ? l10n.t('{0} character over {1} in current line', charactersOver, threshold) + : l10n.t('{0} characters over {1} in current line', charactersOver, threshold); + const diagnostic = new Diagnostic(line.range, lineLengthMessage, this.severity); diagnostic.code = DiagnosticCodes.line_length; diagnostics.push(diagnostic); diff --git a/extensions/git/src/editSessionIdentityProvider.ts b/extensions/git/src/editSessionIdentityProvider.ts index 8380f03ecfd94..a3336d441743d 100644 --- a/extensions/git/src/editSessionIdentityProvider.ts +++ b/extensions/git/src/editSessionIdentityProvider.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { RefType } from './api/git'; +import { RefType } from './api/git.constants'; import { Model } from './model'; export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentityProvider, vscode.Disposable { diff --git a/extensions/git/src/fileSystemProvider.ts b/extensions/git/src/fileSystemProvider.ts index 19928863832a5..a09d00bfc2268 100644 --- a/extensions/git/src/fileSystemProvider.ts +++ b/extensions/git/src/fileSystemProvider.ts @@ -131,6 +131,26 @@ export class GitFileSystemProvider implements FileSystemProvider { this.cache = cache; } + private async getOrOpenRepository(uri: string | Uri): Promise { + let repository = this.model.getRepository(uri); + if (repository) { + return repository; + } + + // In case of the empty window, or the agent sessions window, no repositories are open + // so we need to explicitly open a repository before we can serve git content for the + // given git resource. + if (workspace.workspaceFolders === undefined || workspace.isAgentSessionsWorkspace) { + const fsPath = typeof uri === 'string' ? uri : fromGitUri(uri).path; + this.logger.info(`[GitFileSystemProvider][getOrOpenRepository] Opening repository for ${fsPath}`); + + await this.model.openRepository(fsPath, true, true); + repository = this.model.getRepository(uri); + } + + return repository; + } + watch(): Disposable { return EmptyDisposable; } @@ -139,7 +159,11 @@ export class GitFileSystemProvider implements FileSystemProvider { await this.model.isInitialized; const { submoduleOf, path, ref } = fromGitUri(uri); - const repository = submoduleOf ? this.model.getRepository(submoduleOf) : this.model.getRepository(uri); + + const repository = submoduleOf + ? await this.getOrOpenRepository(submoduleOf) + : await this.getOrOpenRepository(uri); + if (!repository) { this.logger.warn(`[GitFileSystemProvider][stat] Repository not found - ${uri.toString()}`); throw FileSystemError.FileNotFound(); @@ -175,7 +199,7 @@ export class GitFileSystemProvider implements FileSystemProvider { const { path, ref, submoduleOf } = fromGitUri(uri); if (submoduleOf) { - const repository = this.model.getRepository(submoduleOf); + const repository = await this.getOrOpenRepository(submoduleOf); if (!repository) { throw FileSystemError.FileNotFound(); @@ -190,7 +214,7 @@ export class GitFileSystemProvider implements FileSystemProvider { } } - const repository = this.model.getRepository(uri); + const repository = await this.getOrOpenRepository(uri); if (!repository) { this.logger.warn(`[GitFileSystemProvider][readFile] Repository not found - ${uri.toString()}`); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5127ae0fbb95f..14e61bc3f8b70 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -10,10 +10,11 @@ import * as cp from 'child_process'; import { fileURLToPath } from 'url'; import which from 'which'; import { EventEmitter } from 'events'; -import * as filetype from 'file-type'; +import { fileTypeFromBuffer } from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; -import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import type { Commit as ApiCommit, Ref, Branch, Remote, LogOptions, Change, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import { RefType, ForcePushMode, GitErrorCodes, Status } from './api/git.constants'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -377,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; export interface ICloneOptions { readonly parentPath: string; + readonly targetName?: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; readonly ref?: string; @@ -432,14 +434,16 @@ export class Git { } async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { - const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; let folderName = baseFolderName; let folderPath = path.join(options.parentPath, folderName); let count = 1; - while (count < 20 && await new Promise(c => exists(folderPath, c))) { - folderName = `${baseFolderName}-${count++}`; - folderPath = path.join(options.parentPath, folderName); + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } } await mkdirp(options.parentPath); @@ -1073,7 +1077,7 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; // Copy or Rename status comes with a number (ex: 'R100'). // We don't need the number, we use only first character of the status. @@ -1138,7 +1142,7 @@ function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; switch (change[0]) { case 'A': @@ -1687,7 +1691,7 @@ export class Repository { } if (!isText) { - const result = await filetype.fromBuffer(buffer); + const result = await fileTypeFromBuffer(buffer); if (!result) { return { mimetype: 'application/octet-stream' }; @@ -2420,7 +2424,7 @@ export class Repository { await this.exec(args, spawnOptions); } - async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { const args = ['pull']; if (options.tags) { @@ -2446,10 +2450,11 @@ export class Repository { } try { - await this.exec(args, { + const result = await this.exec(args, { cancellationToken: options.cancellationToken, env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } }); + return !/Already up to date/i.test(result.stdout); } catch (err) { if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) { err.gitErrorCode = GitErrorCodes.Conflict; diff --git a/extensions/git/src/historyItemDetailsProvider.ts b/extensions/git/src/historyItemDetailsProvider.ts index be0e2b337f8f6..cccdf508fe3a8 100644 --- a/extensions/git/src/historyItemDetailsProvider.ts +++ b/extensions/git/src/historyItemDetailsProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable } from 'vscode'; -import { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Repository } from './repository'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 29e8705e04b35..c658b4c005eec 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -8,7 +8,8 @@ import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, Fil import { Repository, Resource } from './repository'; import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, subject, truncate } from './util'; import { toMultiFileDiffEditorUris } from './uri'; -import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref } from './api/git'; +import { RefType } from './api/git.constants'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index b37ae9c79c5b7..b2690b24a7cdd 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -12,7 +12,7 @@ import { GitDecorations } from './decorationProvider'; import { Askpass } from './askpass'; import { toDisposable, filterEvent, eventToPromise } from './util'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { GitExtension } from './api/git'; +import type { GitExtension } from './api/git'; import { GitProtocolHandler } from './protocolHandler'; import { GitExtensionImpl } from './api/extension'; import * as path from 'path'; diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 1d65d3dc2d755..deecc7c28629a 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -12,7 +12,7 @@ import { Git } from './git'; import * as path from 'path'; import * as fs from 'fs'; import { fromGitUri } from './uri'; -import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Askpass } from './askpass'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/postCommitCommands.ts b/extensions/git/src/postCommitCommands.ts index 69a18114a41e2..50658d14202ba 100644 --- a/extensions/git/src/postCommitCommands.ts +++ b/extensions/git/src/postCommitCommands.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, commands, Disposable, Event, EventEmitter, Memento, Uri, workspace, l10n } from 'vscode'; -import { PostCommitCommandsProvider } from './api/git'; +import type { PostCommitCommandsProvider } from './api/git'; import { IRepositoryResolver, Repository } from './repository'; import { ApiRepository } from './api/api1'; import { dispose } from './util'; diff --git a/extensions/git/src/pushError.ts b/extensions/git/src/pushError.ts index 6222923ff6864..71f564e8fa255 100644 --- a/extensions/git/src/pushError.ts +++ b/extensions/git/src/pushError.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vscode'; -import { PushErrorHandler } from './api/git'; +import type { PushErrorHandler } from './api/git'; export interface IPushErrorHandlerRegistry { registerPushErrorHandler(provider: PushErrorHandler): Disposable; diff --git a/extensions/git/src/quickDiffProvider.ts b/extensions/git/src/quickDiffProvider.ts index 3b1aa64c8faae..961f5387555fd 100644 --- a/extensions/git/src/quickDiffProvider.ts +++ b/extensions/git/src/quickDiffProvider.ts @@ -7,7 +7,7 @@ import { FileType, l10n, LogOutputChannel, QuickDiffProvider, Uri, workspace } f import { IRepositoryResolver, Repository } from './repository'; import { isDescendant, pathEquals } from './util'; import { toGitUri } from './uri'; -import { Status } from './api/git'; +import { Status } from './api/git.constants'; export class GitQuickDiffProvider implements QuickDiffProvider { readonly label = l10n.t('Git Local Changes (Working Tree)'); diff --git a/extensions/git/src/remotePublisher.ts b/extensions/git/src/remotePublisher.ts index 1326776cde4a0..eb8ec7b8e19bb 100644 --- a/extensions/git/src/remotePublisher.ts +++ b/extensions/git/src/remotePublisher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event } from 'vscode'; -import { RemoteSourcePublisher } from './api/git'; +import type { RemoteSourcePublisher } from './api/git'; export interface IRemoteSourcePublisherRegistry { readonly onDidAddRemoteSourcePublisher: Event; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index f3b1afb4689e0..657ecaa553466 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -3,15 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { cp } from '@vscode/fs-copyfile'; import TelemetryReporter from '@vscode/extension-telemetry'; +import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import picomatch from 'picomatch'; -import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProgressLocation, ProgressOptions, RelativePattern, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, Command, commands, CustomExecution, Disposable, Event, EventEmitter, ExcludeSettingOptions, FileDecoration, l10n, LogLevel, LogOutputChannel, Memento, ProcessExecution, ProgressLocation, ProgressOptions, RelativePattern, scm, ShellExecution, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, TabInputNotebookDiff, TabInputTextDiff, TabInputTextMultiDiff, Task, TaskPanelKind, TaskRevealKind, TaskRunOn, tasks, ThemeColor, ThemeIcon, Uri, window, workspace, WorkspaceEdit, WorkspaceFolder } from 'vscode'; import { ActionButton } from './actionButton'; import { ApiRepository } from './api/api1'; -import { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from './api/git'; +import type { Branch, BranchQuery, Change, CommitOptions, DiffChange, FetchOptions, LogOptions, Ref, Remote, RepositoryKind } from './api/git'; +import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; import { AutoFetcher } from './autofetch'; import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection'; import { debounce, memoize, sequentialize, throttle } from './decorators'; @@ -23,7 +26,7 @@ import { IPushErrorHandlerRegistry } from './pushError'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; -import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktree, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktreeFolder, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; import { GitArtifactProvider } from './artifactProvider'; @@ -462,9 +465,9 @@ class DotGitWatcher implements IFileWatcher { const rootWatcher = watch(repository.dotGit.path); this.disposables.push(rootWatcher); - // Ignore changes to the "index.lock" file, and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. + // Ignore changes to the "index.lock" file (including worktree index.lock files), and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. // Watchman creates a cookie file inside the git directory whenever a query is run (https://facebook.github.io/watchman/docs/cookies.html). - const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); + const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock|\/worktrees\/[^/]+\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); this.event = anyEvent(filteredRootWatcher, this.emitter.event); repository.onDidRunGitStatus(this.updateTransientWatchers, this, this.disposables); @@ -929,7 +932,7 @@ export class Repository implements Disposable { // FS changes should trigger `git status`: // - any change inside the repository working tree - // - any change whithin the first level of the `.git` folder, except the folder itself and `index.lock` + // - any change within the first level of the `.git` folder, except the folder itself and `index.lock` (repository and worktree) const onFileChange = anyEvent(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange); onFileChange(this.onFileChange, this, this.disposables); @@ -938,12 +941,17 @@ export class Repository implements Disposable { this.disposables.push(new FileEventLogger(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange, logger)); - // Parent source control - const parentRoot = repository.kind === 'submodule' - ? repository.dotGit.superProjectPath - : repository.kind === 'worktree' && repository.dotGit.commonPath - ? path.dirname(repository.dotGit.commonPath) - : undefined; + // Parent source control. Repositories opened in the Sessions app + // don't use the parent/child relationship and it is expected for + // a worktree repository to be opened while the main repository + // is closed. + const parentRoot = workspace.isAgentSessionsWorkspace + ? undefined + : repository.kind === 'submodule' + ? repository.dotGit.superProjectPath + : repository.kind === 'worktree' && repository.dotGit.commonPath + ? path.dirname(repository.dotGit.commonPath) + : undefined; const parent = parentRoot ? this.repositoryResolver.getRepository(parentRoot)?.sourceControl : undefined; @@ -952,7 +960,7 @@ export class Repository implements Disposable { const icon = repository.kind === 'submodule' ? new ThemeIcon('archive') : repository.kind === 'worktree' - ? isCopilotWorktree(repository.root) + ? isCopilotWorktreeFolder(repository.root) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') : new ThemeIcon('repo'); @@ -965,7 +973,7 @@ export class Repository implements Disposable { // from the Repositories view. this._isHidden = workspace.workspaceFolders === undefined || (repository.kind === 'worktree' && - isCopilotWorktree(repository.root) && parent !== undefined); + isCopilotWorktreeFolder(repository.root) && parent !== undefined); const root = Uri.file(repository.root); this._sourceControl = scm.createSourceControl('git', 'Git', root, icon, this._isHidden, parent); @@ -1223,6 +1231,14 @@ export class Repository implements Disposable { this.repository.diffBetweenWithStats(`${ref1}...${ref2}`, { path, similarityThreshold })); } + diffBetweenWithStats2(ref: string, path?: string): Promise { + const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); + const similarityThreshold = scopedConfig.get('similarityThreshold', 50); + + return this.run(Operation.Diff, () => + this.repository.diffBetweenWithStats(ref, { path, similarityThreshold })); + } + diffTrees(treeish1: string, treeish2?: string): Promise { const scopedConfig = workspace.getConfiguration('git', Uri.file(this.root)); const similarityThreshold = scopedConfig.get('similarityThreshold', 50); @@ -1890,15 +1906,41 @@ export class Repository implements Disposable { this.globalState.update(`${Repository.WORKTREE_ROOT_STORAGE_KEY}:${this.root}`, newWorktreeRoot); } - // Copy worktree include files. We explicitly do not await this - // since we don't want to block the worktree creation on the - // copy operation. - this._copyWorktreeIncludeFiles(worktreePath!); + this._setupWorktree(worktreePath!); return worktreePath!; }); } + private async _setupWorktree(worktreePath: string): Promise { + // Copy worktree include files and wait for the copy to complete + // before running any worktree-created tasks. + await this._copyWorktreeIncludeFiles(worktreePath); + + await this._runWorktreeCreatedTasks(worktreePath); + } + + private async _runWorktreeCreatedTasks(worktreePath: string): Promise { + try { + const allTasks = await tasks.fetchTasks(); + const worktreeTasks = allTasks.filter(task => task.runOptions.runOn === TaskRunOn.WorktreeCreated); + + for (const task of worktreeTasks) { + const worktreeTask = retargetTaskToWorktree(task, worktreePath); + if (!worktreeTask) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Skipped task '${task.name}' because it could not be retargeted to worktree '${worktreePath}'.`); + continue; + } + + tasks.executeTask(worktreeTask).then(undefined, err => { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created task '${task.name}' for '${worktreePath}': ${err}`); + }); + } + } catch (err) { + this.logger.warn(`[Repository][_runWorktreeCreatedTasks] Failed to execute worktree-created tasks for '${worktreePath}': ${err}`); + } + } + private async _getWorktreeIncludePaths(): Promise> { const config = workspace.getConfiguration('git', Uri.file(this.root)); const worktreeIncludeFiles = config.get('worktreeIncludeFiles', []); @@ -1928,59 +1970,76 @@ export class Repository implements Disposable { gitIgnoredFiles.delete(uri.fsPath); } - // Add the folder paths for git ignored files + // Compute the base directory for each glob pattern (the fixed + // prefix before any wildcard characters). This will be used to + // optimize the upward traversal when adding parent directories. + const filePatternBases = new Set(); + for (const pattern of worktreeIncludeFiles) { + const segments = pattern.split(/[\/\\]/); + const fixedSegments: string[] = []; + for (const seg of segments) { + if (/[*?{}[\]]/.test(seg)) { + break; + } + fixedSegments.push(seg); + } + filePatternBases.add(path.join(this.root, ...fixedSegments)); + } + + // Add the folder paths for git ignored files, walking + // up only to the nearest file pattern base directory. const gitIgnoredPaths = new Set(gitIgnoredFiles); for (const filePath of gitIgnoredFiles) { let dir = path.dirname(filePath); - while (dir !== this.root && !gitIgnoredFiles.has(dir)) { + while (dir !== this.root && !gitIgnoredPaths.has(dir)) { gitIgnoredPaths.add(dir); + if (filePatternBases.has(dir)) { + break; + } dir = path.dirname(dir); } } - return gitIgnoredPaths; + // Find minimal set of paths (folders and files) to copy. Keep only topmost + // paths — if a directory is already in the set, all its descendants are + // implicitly included and don't need separate entries. + let lastTopmost: string | undefined; + const pathsToCopy = new Set(); + for (const p of Array.from(gitIgnoredPaths).sort()) { + if (lastTopmost && (p === lastTopmost || p.startsWith(lastTopmost + path.sep))) { + continue; + } + pathsToCopy.add(p); + lastTopmost = p; + } + + return pathsToCopy; } private async _copyWorktreeIncludeFiles(worktreePath: string): Promise { - const gitIgnoredPaths = await this._getWorktreeIncludePaths(); - if (gitIgnoredPaths.size === 0) { + const worktreeIncludePaths = await this._getWorktreeIncludePaths(); + if (worktreeIncludePaths.size === 0) { return; } try { - // Find minimal set of paths (folders and files) to copy. - // The goal is to reduce the number of copy operations - // needed. - const pathsToCopy = new Set(); - for (const filePath of gitIgnoredPaths) { - const relativePath = path.relative(this.root, filePath); - const firstSegment = relativePath.split(path.sep)[0]; - pathsToCopy.add(path.join(this.root, firstSegment)); - } - - const startTime = Date.now(); + const startTime = performance.now(); const limiter = new Limiter(15); - const files = Array.from(pathsToCopy); + const files = Array.from(worktreeIncludePaths); // Copy files - const results = await Promise.allSettled(files.map(sourceFile => - limiter.queue(async () => { + const results = await Promise.allSettled(files.map(sourceFile => { + return limiter.queue(async () => { const targetFile = path.join(worktreePath, relativePath(this.root, sourceFile)); await fsPromises.mkdir(path.dirname(targetFile), { recursive: true }); - await fsPromises.cp(sourceFile, targetFile, { - filter: src => gitIgnoredPaths.has(src), - force: true, - mode: fs.constants.COPYFILE_FICLONE, - recursive: true, - verbatimSymlinks: true - }); - }) - )); + await cp(sourceFile, targetFile, { force: true, recursive: true, verbatimSymlinks: true }); + }); + })); // Log any failed operations const failedOperations = results.filter(r => r.status === 'rejected'); - this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${files.length - failedOperations.length}/${files.length} folder(s)/file(s) to worktree. [${Date.now() - startTime}ms]`); + this.logger.info(`[Repository][_copyWorktreeIncludeFiles] Copied ${files.length - failedOperations.length}/${files.length} folder(s)/file(s) to worktree. [${(performance.now() - startTime).toFixed(2)}ms]`); if (failedOperations.length > 0) { window.showWarningMessage(l10n.t('Failed to copy {0} folder(s)/file(s) to the worktree.', failedOperations.length)); @@ -3293,7 +3352,110 @@ export class Repository implements Disposable { return this.unpublishedCommits; } + async generateRandomBranchName(): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const branchRandomNameEnabled = config.get('branchRandomName.enable', false); + + if (!branchRandomNameEnabled) { + return undefined; + } + + const branchPrefix = config.get('branchPrefix', ''); + const branchWhitespaceChar = config.get('branchWhitespaceChar', '-'); + const branchRandomNameDictionary = config.get('branchRandomName.dictionary', ['adjectives', 'animals']); + + const dictionaries: string[][] = []; + for (const dictionary of branchRandomNameDictionary) { + if (dictionary.toLowerCase() === 'adjectives') { + dictionaries.push(adjectives); + } + if (dictionary.toLowerCase() === 'animals') { + dictionaries.push(animals); + } + if (dictionary.toLowerCase() === 'colors') { + dictionaries.push(colors); + } + if (dictionary.toLowerCase() === 'numbers') { + dictionaries.push(NumberDictionary.generate({ length: 3 })); + } + } + + if (dictionaries.length === 0) { + return undefined; + } + + // 5 attempts to generate a random branch name + for (let index = 0; index < 5; index++) { + const randomName = uniqueNamesGenerator({ + dictionaries, + length: dictionaries.length, + separator: branchWhitespaceChar + }); + + // Check for local ref conflict + const refs = await this.getRefs({ pattern: `refs/heads/${branchPrefix}${randomName}` }); + if (refs.length === 0) { + return `${branchPrefix}${randomName}`; + } + } + + return undefined; + } + dispose(): void { this.disposables = dispose(this.disposables); } } + +function retargetTaskToWorktree(task: Task, worktreePath: string): Task | undefined { + const execution = retargetTaskExecution(task.execution, worktreePath); + if (!execution) { + return undefined; + } + + const worktreeFolder: WorkspaceFolder = { + uri: Uri.file(worktreePath), + name: path.basename(worktreePath), + index: workspace.workspaceFolders?.length ?? 0 + }; + + const worktreeTask = new Task({ ...task.definition }, worktreeFolder, task.name, task.source, execution, task.problemMatchers); + worktreeTask.detail = task.detail; + worktreeTask.group = task.group; + worktreeTask.isBackground = task.isBackground; + worktreeTask.presentationOptions = { ...task.presentationOptions, reveal: TaskRevealKind.Never, panel: TaskPanelKind.New }; + worktreeTask.runOptions = { ...task.runOptions }; + + return worktreeTask; +} + +function retargetTaskExecution(execution: ProcessExecution | ShellExecution | CustomExecution | undefined, worktreePath: string): ProcessExecution | ShellExecution | CustomExecution | undefined { + if (!execution) { + return undefined; + } + + if (execution instanceof ProcessExecution) { + return new ProcessExecution(execution.process, execution.args, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution instanceof ShellExecution) { + if (execution.commandLine !== undefined) { + return new ShellExecution(execution.commandLine, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution.command !== undefined) { + return new ShellExecution(execution.command, execution.args ?? [], { + ...execution.options, + cwd: worktreePath + }); + } + } + + return execution; +} diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index 6aa998b7679bb..8f03d8998c771 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -5,7 +5,7 @@ import { LogOutputChannel, Memento, Uri, workspace } from 'vscode'; import { LRUCache } from './cache'; -import { Remote, RepositoryAccessDetails } from './api/git'; +import type { Remote, RepositoryAccessDetails } from './api/git'; import { isDescendant } from './util'; export interface RepositoryCacheInfo { diff --git a/extensions/git/src/statusbar.ts b/extensions/git/src/statusbar.ts index d5cbe86ee7c88..32fb1f588642b 100644 --- a/extensions/git/src/statusbar.ts +++ b/extensions/git/src/statusbar.ts @@ -6,7 +6,8 @@ import { Disposable, Command, EventEmitter, Event, workspace, Uri, l10n } from 'vscode'; import { Repository } from './repository'; import { anyEvent, dispose, filterEvent } from './util'; -import { Branch, RefType, RemoteSourcePublisher } from './api/git'; +import type { Branch, RemoteSourcePublisher } from './api/git'; +import { RefType } from './api/git.constants'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { CheckoutOperation, CheckoutTrackingOperation, OperationKind } from './operation'; diff --git a/extensions/git/src/test/smoke.test.ts b/extensions/git/src/test/smoke.test.ts index d9a5776824b2e..c2870a2631ee3 100644 --- a/extensions/git/src/test/smoke.test.ts +++ b/extensions/git/src/test/smoke.test.ts @@ -9,7 +9,8 @@ import { workspace, commands, window, Uri, WorkspaceEdit, Range, TextDocument, e import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { GitExtension, API, Repository, Status } from '../api/git'; +import type { GitExtension, API, Repository } from '../api/git'; +import { Status } from '../api/git.constants'; import { eventToPromise } from '../util'; suite('git smoke test', function () { diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 1ccf04a423d8d..a07eb4bfba78e 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -12,7 +12,7 @@ import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; import { truncate } from './util'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -import { AvatarQuery, AvatarQueryCommit } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit } from './api/git'; import { getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover'; export class GitTimelineItem extends TimelineItem { diff --git a/extensions/git/src/uri.ts b/extensions/git/src/uri.ts index 8b04fabe583eb..1d79e67e8e67b 100644 --- a/extensions/git/src/uri.ts +++ b/extensions/git/src/uri.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Uri } from 'vscode'; -import { Change, Status } from './api/git'; +import type { Change } from './api/git'; +import { Status } from './api/git.constants'; export interface GitUriParams { path: string; diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index c6ec6ece45c69..58a6d06419a78 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri, DiagnosticSeverity, env, SourceControlHistoryItem } from 'vscode'; -import { dirname, normalize, sep, relative } from 'path'; +import { basename, dirname, normalize, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; import byline from 'byline'; @@ -867,10 +867,6 @@ export function getStashDescription(stash: Stash): string | undefined { return descriptionSegments.join(' \u2022 '); } -export function isCopilotWorktree(path: string): boolean { - const lastSepIndex = path.lastIndexOf(sep); - - return lastSepIndex !== -1 - ? path.substring(lastSepIndex + 1).startsWith('copilot-worktree-') - : path.startsWith('copilot-worktree-'); +export function isCopilotWorktreeFolder(path: string): boolean { + return basename(path).startsWith('copilot-'); } diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 2a7ad5259acca..a34d12aaa4838 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -27,6 +27,7 @@ "../../src/vscode-dts/vscode.proposed.scmMultiDiffEditor.d.ts", "../../src/vscode-dts/vscode.proposed.scmTextDocument.d.ts", "../../src/vscode-dts/vscode.proposed.statusBarItemTooltip.d.ts", + "../../src/vscode-dts/vscode.proposed.taskRunOptions.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputMultiDiff.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts", diff --git a/extensions/github-authentication/.vscodeignore b/extensions/github-authentication/.vscodeignore index 0f1797efe9561..fd8583ab8d125 100644 --- a/extensions/github-authentication/.vscodeignore +++ b/extensions/github-authentication/.vscodeignore @@ -3,7 +3,7 @@ src/** !src/common/config.json out/** build/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild.mts +esbuild.browser.mts tsconfig*.json package-lock.json diff --git a/extensions/github-authentication/esbuild.browser.mts b/extensions/github-authentication/esbuild.browser.mts new file mode 100644 index 0000000000000..20745e1d0870e --- /dev/null +++ b/extensions/github-authentication/esbuild.browser.mts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import type { Plugin } from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Plugin that rewrites `./node/*` imports to `./browser/*` for the web build, + * replacing the platform-specific implementations with their browser equivalents. + */ +const platformModulesPlugin: Plugin = { + name: 'platform-modules', + setup(build) { + build.onResolve({ filter: /\/node\// }, args => { + if (args.kind !== 'import-statement' || !args.resolveDir) { + return; + } + const remapped = args.path.replace('/node/', '/browser/'); + return build.resolve(remapped, { resolveDir: args.resolveDir, kind: args.kind }); + }); + }, +}; + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [platformModulesPlugin], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/github/extension.webpack.config.js b/extensions/github-authentication/esbuild.mts similarity index 51% rename from extensions/github/extension.webpack.config.js rename to extensions/github-authentication/esbuild.mts index 9e2b191a389d4..2b75ca703da06 100644 --- a/extensions/github/extension.webpack.config.js +++ b/extensions/github-authentication/esbuild.mts @@ -2,22 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - }, - output: { - libraryTarget: 'module', - chunkFormat: 'module', - }, - externals: { - 'vscode': 'module vscode', +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, - experiments: { - outputModule: true - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/github-authentication/extension-browser.webpack.config.js b/extensions/github-authentication/extension-browser.webpack.config.js deleted file mode 100644 index 70a7fd87cf4a3..0000000000000 --- a/extensions/github-authentication/extension-browser.webpack.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - node: false, - entry: { - extension: './src/extension.ts', - }, - resolve: { - alias: { - 'uuid': path.resolve(import.meta.dirname, 'node_modules/uuid/dist/esm-browser/index.js'), - './node/authServer': path.resolve(import.meta.dirname, 'src/browser/authServer'), - './node/crypto': path.resolve(import.meta.dirname, 'src/browser/crypto'), - './node/fetch': path.resolve(import.meta.dirname, 'src/browser/fetch'), - './node/buffer': path.resolve(import.meta.dirname, 'src/browser/buffer'), - } - } -}); diff --git a/extensions/github-authentication/media/index.html b/extensions/github-authentication/media/index.html index 3292e2a08fc9f..2df45293528fa 100644 --- a/extensions/github-authentication/media/index.html +++ b/extensions/github-authentication/media/index.html @@ -30,9 +30,17 @@

Launching

- ${this._getStyles(resourceProvider, sourceUri, config, imageInfo)} + + ${this.#getStyles(resourceProvider, sourceUri, config, imageInfo)} - ${this._getScripts(resourceProvider, nonce)} + ${this.#getScripts(resourceProvider, nonce)} `; return { @@ -119,7 +131,7 @@ export class MdDocumentRenderer { markdownDocument: vscode.TextDocument, resourceProvider: WebviewResourceProvider, ): Promise { - const rendered = await this._engine.render(markdownDocument, resourceProvider); + const rendered = await this.#engine.render(markdownDocument, resourceProvider); const html = `
${rendered.html}
`; return { html, @@ -138,13 +150,13 @@ export class MdDocumentRenderer { `; } - private _extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string { + #extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string { const webviewResource = resourceProvider.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'media', mediaFile)); + vscode.Uri.joinPath(this.#context.extensionUri, 'media', mediaFile)); return webviewResource.toString(); } - private _fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string { + #fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string { if (!href) { return href; } @@ -168,18 +180,18 @@ export class MdDocumentRenderer { return resourceProvider.asWebviewUri(vscode.Uri.joinPath(uri.Utils.dirname(resource), href)).toString(); } - private _computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string { + #computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string { if (!Array.isArray(config.styles)) { return ''; } const out: string[] = []; for (const style of config.styles) { - out.push(``); + out.push(``); } return out.join('\n'); } - private _getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string { + #getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string { return [ config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '', isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`, @@ -187,7 +199,7 @@ export class MdDocumentRenderer { ].join(' '); } - private _getImageStabilizerStyles(imageInfo: readonly ImageInfo[]): string { + #getImageStabilizerStyles(imageInfo: readonly ImageInfo[]): string { if (!imageInfo.length) { return ''; } @@ -204,20 +216,20 @@ export class MdDocumentRenderer { return ret; } - private _getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, imageInfo: readonly ImageInfo[]): string { + #getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, imageInfo: readonly ImageInfo[]): string { const baseStyles: string[] = []; - for (const resource of this._contributionProvider.contributions.previewStyles) { + for (const resource of this.#contributionProvider.contributions.previewStyles) { baseStyles.push(``); } return `${baseStyles.join('\n')} - ${this._computeCustomStyleSheetIncludes(resourceProvider, resource, config)} - ${this._getImageStabilizerStyles(imageInfo)}`; + ${this.#computeCustomStyleSheetIncludes(resourceProvider, resource, config)} + ${this.#getImageStabilizerStyles(imageInfo)}`; } - private _getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string { + #getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string { const out: string[] = []; - for (const resource of this._contributionProvider.contributions.previewScripts) { + for (const resource of this.#contributionProvider.contributions.previewScripts) { out.push(` + + + + + +`; +} + +/** Recursively collect *.css paths relative to `dir`. */ +function collectCssFiles(dir, prefix) { + let results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? prefix + '/' + entry.name : entry.name; + if (entry.isDirectory()) { + results = results.concat(collectCssFiles(path.join(dir, entry.name), rel)); + } else if (entry.name.endsWith('.css')) { + results.push(rel); + } + } + return results; +} + +main(); + diff --git a/scripts/code-sessions-web.sh b/scripts/code-sessions-web.sh new file mode 100755 index 0000000000000..be62921a05f28 --- /dev/null +++ b/scripts/code-sessions-web.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +if [[ "$OSTYPE" == "darwin"* ]]; then + realpath() { [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"; } + ROOT=$(dirname $(dirname $(realpath "$0"))) +else + ROOT=$(dirname $(dirname $(readlink -f $0))) +fi + +function code() { + cd $ROOT + + # Sync built-in extensions + npm run download-builtin-extensions + + NODE=$(node build/lib/node.ts) + if [ ! -e $NODE ];then + # Load remote node + npm run gulp node + fi + + NODE=$(node build/lib/node.ts) + + $NODE ./scripts/code-sessions-web.js "$@" +} + +code "$@" diff --git a/scripts/sync-agent-host-protocol.ts b/scripts/sync-agent-host-protocol.ts new file mode 100644 index 0000000000000..02469d1705189 --- /dev/null +++ b/scripts/sync-agent-host-protocol.ts @@ -0,0 +1,229 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Copies type definitions from the sibling `agent-host-protocol` repo into +// `src/vs/platform/agentHost/common/state/protocol/`. Run via: +// +// npx tsx scripts/sync-agent-host-protocol.ts +// +// Transformations applied: +// 1. Converts 2-space indentation to tabs. +// 2. Merges duplicate imports from the same module. +// 3. Formats with the project's tsfmt.json settings. +// 4. Adds Microsoft copyright header. +// +// URI stays as `string` (the protocol's canonical representation). VS Code code +// should call `URI.parse()` at point-of-use where a URI class is needed. + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import * as ts from 'typescript'; + +const ROOT = path.resolve(__dirname, '..'); +const PROTOCOL_REPO = path.resolve(ROOT, '../agent-host-protocol'); +const TYPES_DIR = path.join(PROTOCOL_REPO, 'types'); +const DEST_DIR = path.join(ROOT, 'src/vs/platform/agentHost/common/state/protocol'); + +// Load tsfmt.json formatting options once +const TSFMT_PATH = path.join(ROOT, 'tsfmt.json'); +const FORMAT_OPTIONS: ts.FormatCodeSettings = JSON.parse(fs.readFileSync(TSFMT_PATH, 'utf-8')); + +/** + * Formats a TypeScript source string using the TypeScript language service + * formatter with the project's tsfmt.json settings. + */ +function formatTypeScript(content: string, fileName: string): string { + const host: ts.LanguageServiceHost = { + getCompilationSettings: () => ({}), + getScriptFileNames: () => [fileName], + getScriptVersion: () => '1', + getScriptSnapshot: (name: string) => name === fileName ? ts.ScriptSnapshot.fromString(content) : undefined, + getCurrentDirectory: () => ROOT, + getDefaultLibFileName: () => '', + fileExists: () => false, + readFile: () => undefined, + }; + const ls = ts.createLanguageService(host); + const edits = ls.getFormattingEditsForDocument(fileName, FORMAT_OPTIONS); + // Apply edits in reverse order to preserve offsets + for (let i = edits.length - 1; i >= 0; i--) { + const edit = edits[i]; + content = content.substring(0, edit.span.start) + edit.newText + content.substring(edit.span.start + edit.span.length); + } + ls.dispose(); + return content; +} + +const COPYRIGHT = `/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/`; + +const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts'; + +// Files to copy. All go into protocol/. +const FILES: { src: string; dest: string }[] = [ + { src: 'state.ts', dest: 'state.ts' }, + { src: 'actions.ts', dest: 'actions.ts' }, + { src: 'action-origin.generated.ts', dest: 'action-origin.generated.ts' }, + { src: 'reducers.ts', dest: 'reducers.ts' }, + { src: 'commands.ts', dest: 'commands.ts' }, + { src: 'errors.ts', dest: 'errors.ts' }, + { src: 'notifications.ts', dest: 'notifications.ts' }, + { src: 'messages.ts', dest: 'messages.ts' }, + { src: 'version/registry.ts', dest: 'version/registry.ts' }, +]; + +function getSourceCommitHash(): string { + try { + return execSync('git rev-parse --short HEAD', { cwd: PROTOCOL_REPO, encoding: 'utf-8' }).trim(); + } catch { + return 'unknown'; + } +} + +function stripExistingHeader(content: string): string { + return content.replace(/^\/\*\*?[\s\S]*?\*\/\s*/, ''); +} + +function convertIndentation(content: string): string { + const lines = content.split('\n'); + return lines.map(line => { + const match = line.match(/^( +)/); + if (!match) { + return line; + } + const spaces = match[1].length; + const tabs = Math.floor(spaces / 2); + const remainder = spaces % 2; + return '\t'.repeat(tabs) + ' '.repeat(remainder) + line.slice(spaces); + }).join('\n'); +} + +/** + * Merges duplicate imports from the same module. + * Combines `import type { A }` and `import { B }` from the same module into + * `import { B, type A }` to satisfy the no-duplicate-imports lint rule. + */ +function mergeDuplicateImports(content: string): string { + // Collapse multi-line imports into single lines first + content = content.replace(/import\s+(type\s+)?\{([^}]+)\}\s+from\s+'([^']+)';/g, (_match, typeKeyword, names, mod) => { + const collapsed = names.replace(/\s+/g, ' ').trim(); + return typeKeyword ? `import type { ${collapsed} } from '${mod}';` : `import { ${collapsed} } from '${mod}';`; + }); + + const importsByModule = new Map(); + const otherLines: string[] = []; + const seenModules = new Set(); + + for (const line of content.split('\n')) { + const typeMatch = line.match(/^import type \{([^}]+)\} from '([^']+)';$/); + const valueMatch = line.match(/^import \{([^}]+)\} from '([^']+)';$/); + + if (typeMatch) { + const [, names, mod] = typeMatch; + if (!importsByModule.has(mod)) { + importsByModule.set(mod, { typeNames: [], valueNames: [] }); + } + importsByModule.get(mod)!.typeNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0)); + if (!seenModules.has(mod)) { + seenModules.add(mod); + otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`); + } + } else if (valueMatch) { + const [, names, mod] = valueMatch; + if (!importsByModule.has(mod)) { + importsByModule.set(mod, { typeNames: [], valueNames: [] }); + } + importsByModule.get(mod)!.valueNames.push(...names.split(',').map(s => s.trim()).filter(s => s.length > 0)); + if (!seenModules.has(mod)) { + seenModules.add(mod); + otherLines.push(`__IMPORT_PLACEHOLDER__${mod}`); + } + } else { + otherLines.push(line); + } + } + + return otherLines.map(line => { + if (line.startsWith('__IMPORT_PLACEHOLDER__')) { + const mod = line.substring('__IMPORT_PLACEHOLDER__'.length); + const entry = importsByModule.get(mod)!; + const uniqueTypes = [...new Set(entry.typeNames)]; + const uniqueValues = [...new Set(entry.valueNames)]; + + if (uniqueValues.length > 0 && uniqueTypes.length > 0) { + const allNames = [...uniqueValues, ...uniqueTypes.map(n => `type ${n}`)]; + return `import { ${allNames.join(', ')} } from '${mod}';`; + } else if (uniqueValues.length > 0) { + return `import { ${uniqueValues.join(', ')} } from '${mod}';`; + } else { + return `import type { ${uniqueTypes.join(', ')} } from '${mod}';`; + } + } + return line; + }).join('\n'); +} + + + + + +function processFile(src: string, dest: string, commitHash: string): void { + let content = fs.readFileSync(src, 'utf-8'); + content = stripExistingHeader(content); + + // Merge duplicate imports from the same module + content = mergeDuplicateImports(content); + + content = convertIndentation(content); + content = content.split('\n').map(line => line.trimEnd()).join('\n'); + + const header = `${COPYRIGHT}\n\n${BANNER}\n// Synced from agent-host-protocol @ ${commitHash}\n`; + content = header + '\n' + content; + + if (!content.endsWith('\n')) { + content += '\n'; + } + + const destPath = path.join(DEST_DIR, dest); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + content = formatTypeScript(content, dest); + fs.writeFileSync(destPath, content, 'utf-8'); + console.log(` ${dest}`); +} + +// ---- Main ------------------------------------------------------------------- + +function main() { + if (!fs.existsSync(TYPES_DIR)) { + console.error(`ERROR: Cannot find ${TYPES_DIR}`); + console.error('Clone agent-host-protocol as a sibling of the VS Code repo:'); + console.error(' git clone git@github.com:microsoft/agent-host-protocol.git ../agent-host-protocol'); + process.exit(1); + } + + const commitHash = getSourceCommitHash(); + console.log(`Syncing from agent-host-protocol @ ${commitHash}`); + console.log(` Source: ${TYPES_DIR}`); + console.log(` Dest: ${DEST_DIR}`); + console.log(); + + // Copy protocol files + for (const file of FILES) { + const srcPath = path.join(TYPES_DIR, file.src); + if (!fs.existsSync(srcPath)) { + console.error(` SKIP (not found): ${file.src}`); + continue; + } + processFile(srcPath, file.dest, commitHash); + } + + console.log(); + console.log('Done.'); +} + +main(); diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index 3bd5c73a0af64..8ccabe764d178 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -17,6 +17,7 @@ import { join } from 'node:path'; // SEE https://nodejs.org/docs/latest/api/module.html#initialize const _specifierToUrl: Record = {}; +const _specifierToFormat: Record = {}; export async function initialize(injectPath: string): Promise { // populate mappings @@ -27,16 +28,52 @@ export async function initialize(injectPath: string): Promise { for (const [name] of Object.entries(packageJSON.dependencies)) { try { const path = join(injectPackageJSONPath, `../node_modules/${name}/package.json`); - let { main } = JSON.parse(String(await promises.readFile(path))); + const pkgJson = JSON.parse(String(await promises.readFile(path))); + + // Determine the entry point: prefer exports["."].import for ESM, then main. + // Handle conditional export targets where exports["."].import/default + // can be a string or an object with a string `default` field. + // (Added for copilot-sdk) + let main: string | undefined; + if (pkgJson.exports?.['.']) { + const dotExport = pkgJson.exports['.']; + if (typeof dotExport === 'string') { + main = dotExport; + } else if (typeof dotExport === 'object' && dotExport !== null) { + const resolveCondition = (v: unknown): string | undefined => { + if (typeof v === 'string') { + return v; + } + if (typeof v === 'object' && v !== null) { + const d = (v as { default?: unknown }).default; + if (typeof d === 'string') { + return d; + } + } + return undefined; + }; + main = resolveCondition(dotExport.import) ?? resolveCondition(dotExport.default); + } + } + if (typeof main !== 'string') { + main = typeof pkgJson.main === 'string' ? pkgJson.main : undefined; + } if (!main) { main = 'index.js'; } - if (!main.endsWith('.js')) { + if (!main.endsWith('.js') && !main.endsWith('.mjs') && !main.endsWith('.cjs')) { main += '.js'; } const mainPath = join(injectPackageJSONPath, `../node_modules/${name}/${main}`); _specifierToUrl[name] = pathToFileURL(mainPath).href; + // Determine module format: .mjs is always ESM, .cjs always CJS, otherwise check type field + const isModule = main.endsWith('.mjs') + ? true + : main.endsWith('.cjs') + ? false + : pkgJson.type === 'module'; + _specifierToFormat[name] = isModule ? 'module' : 'commonjs'; } catch (err) { console.error(name); @@ -52,7 +89,7 @@ export async function resolve(specifier: string | number, context: unknown, next const newSpecifier = _specifierToUrl[specifier]; if (newSpecifier !== undefined) { return { - format: 'commonjs', + format: _specifierToFormat[specifier] ?? 'commonjs', shortCircuit: true, url: newSpecifier }; diff --git a/src/main.ts b/src/main.ts index ec2e45c31d255..42f599c9b3785 100644 --- a/src/main.ts +++ b/src/main.ts @@ -342,7 +342,7 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { app.commandLine.appendSwitch('disable-blink-features', blinkFeaturesToDisable); // Support JS Flags - const jsFlags = getJSFlags(cliArgs); + const jsFlags = getJSFlags(cliArgs, argvConfig); if (jsFlags) { app.commandLine.appendSwitch('js-flags', jsFlags); } @@ -374,6 +374,7 @@ interface IArgvConfig { readonly 'use-inmemory-secretstorage'?: boolean; readonly 'enable-rdp-display-tracking'?: boolean; readonly 'remote-debugging-port'?: string; + readonly 'js-flags'?: string; } function readArgvConfigSync(): IArgvConfig { @@ -537,7 +538,7 @@ function configureCrashReporter(): void { }); } -function getJSFlags(cliArgs: NativeParsedArgs): string | null { +function getJSFlags(cliArgs: NativeParsedArgs, argvConfig: IArgvConfig): string | null { const jsFlags: string[] = []; // Add any existing JS flags we already got from the command line @@ -545,6 +546,11 @@ function getJSFlags(cliArgs: NativeParsedArgs): string | null { jsFlags.push(cliArgs['js-flags']); } + // Add JS flags from runtime arguments (argv.json) + if (typeof argvConfig['js-flags'] === 'string' && argvConfig['js-flags']) { + jsFlags.push(argvConfig['js-flags']); + } + if (process.platform === 'linux') { // Fix cppgc crash on Linux with 16KB page size. // Refs https://issues.chromium.org/issues/378017037 diff --git a/src/server-main.ts b/src/server-main.ts index a589510cfc8a0..f5af9e32e6a65 100644 --- a/src/server-main.ts +++ b/src/server-main.ts @@ -25,7 +25,7 @@ perf.mark('code/server/start'); // Do a quick parse to determine if a server or the cli needs to be started const parsedArgs = minimist(process.argv.slice(2), { boolean: ['start-server', 'list-extensions', 'print-ip-address', 'help', 'version', 'accept-server-license-terms', 'update-extensions'], - string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility'], + string: ['install-extension', 'install-builtin-extension', 'uninstall-extension', 'locate-extension', 'socket-path', 'host', 'port', 'compatibility', 'agent-host-port', 'agent-host-path'], alias: { help: 'h', version: 'v' } }); ['host', 'port', 'accept-server-license-terms'].forEach(e => { diff --git a/src/tsconfig.vscode-dts.json b/src/tsconfig.vscode-dts.json index b83f686e4f3d3..fae0ce15c38f1 100644 --- a/src/tsconfig.vscode-dts.json +++ b/src/tsconfig.vscode-dts.json @@ -1,7 +1,7 @@ { "compilerOptions": { "noEmit": true, - "module": "None", + "module": "preserve", "experimentalDecorators": false, "noImplicitReturns": true, "noImplicitOverride": true, diff --git a/src/typings/base-common.d.ts b/src/typings/base-common.d.ts index 56e9a6a799d7e..9028abb2975b5 100644 --- a/src/typings/base-common.d.ts +++ b/src/typings/base-common.d.ts @@ -25,7 +25,7 @@ declare global { function setTimeout(handler: string | Function, timeout?: number, ...arguments: any[]): Timeout; function clearTimeout(timeout: Timeout | undefined): void; - function setInterval(callback: (...args: any[]) => void, delay?: number, ...args: any[]): Timeout; + function setInterval(callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]): Timeout; function clearInterval(timeout: Timeout | undefined): void; diff --git a/src/vs/amdX.ts b/src/vs/amdX.ts index 374d4f19faf13..98290fdc2b421 100644 --- a/src/vs/amdX.ts +++ b/src/vs/amdX.ts @@ -171,15 +171,15 @@ class AMDModuleImporter { if (this._amdPolicy) { scriptSrc = this._amdPolicy.createScriptURL(scriptSrc) as unknown as string; } - await import(scriptSrc); + await import(/* @vite-ignore */ scriptSrc); return this._defineCalls.pop(); } private async _nodeJSLoadScript(scriptSrc: string): Promise { try { - const fs = (await import(`${'fs'}`)).default; - const vm = (await import(`${'vm'}`)).default; - const module = (await import(`${'module'}`)).default; + const fs = (await import(/* @vite-ignore */ `${'fs'}`)).default; + const vm = (await import(/* @vite-ignore */ `${'vm'}`)).default; + const module = (await import(/* @vite-ignore */ `${'module'}`)).default; const filePath = URI.parse(scriptSrc).fsPath; const content = fs.readFileSync(filePath).toString(); diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index e3f20d96726b4..52f99538b1144 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -66,6 +66,25 @@ export interface MarkdownSanitizerConfig { readonly remoteImageIsAllowed?: (uri: URI) => boolean; } +/** + * Returns a human-readable tooltip string for a link href. + * For file:// URIs, converts to a decoded OS file system path to avoid + * showing raw URL-encoded paths (e.g. "C:\Users\..." instead of "file:///c%3A/Users/..."). + */ +function getLinkTitle(href: string): string { + try { + const parsed = URI.parse(href); + if (parsed.scheme === Schemas.file) { + const path = parsed.fsPath; + const fragment = parsed.fragment; + return escapeDoubleQuotes(fragment ? `${path}#${fragment}` : path); + } + } catch { + // fall through + } + return ''; +} + const defaultMarkedRenderers = Object.freeze({ image: ({ href, title, text }: marked.Tokens.Image): string => { let dimensions: string[] = []; @@ -104,6 +123,12 @@ const defaultMarkedRenderers = Object.freeze({ title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; href = removeMarkdownEscapes(href); + // For file:// URIs without an explicit title, show the decoded OS path instead of + // the raw URL-encoded URI (e.g. display "C:\Users\..." instead of "file:///c%3A/Users/...") + if (!title && href.startsWith(`${Schemas.file}:`)) { + title = getLinkTitle(href); + } + // HTML Encode href href = href.replace(/&/g, '&') .replace(/ .dialog-icon.codicon { - flex: 0 0 48px; - height: 48px; - font-size: 48px; + flex: 0 0 24px; + height: 24px; + font-size: 24px; } .monaco-dialog-box.align-vertical .dialog-message-row > .dialog-icon.codicon { @@ -76,12 +84,17 @@ align-self: baseline; } +.monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { + align-self: stretch; /* fill row height so overflow-y scrolling works */ +} + /** Dialog: Message/Footer Container */ .monaco-dialog-box .dialog-message-row .dialog-message-container, .monaco-dialog-box .dialog-footer-row { display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; text-overflow: ellipsis; user-select: text; -webkit-user-select: text; @@ -95,7 +108,7 @@ .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container, .monaco-dialog-box:not(.align-vertical) .dialog-footer-row { - padding-left: 24px; + padding-left: 12px; } .monaco-dialog-box.align-vertical .dialog-message-row .dialog-message-container, @@ -111,20 +124,20 @@ /** Dialog: Message */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { - line-height: 22px; - font-size: 18px; + font-size: 14px; + font-weight: 600; flex: 1; /* let the message always grow */ white-space: normal; word-wrap: break-word; /* never overflow long words, but break to next line */ - min-height: 48px; /* matches icon height */ - margin-bottom: 8px; + min-height: 22px; + margin-bottom: 4px; display: flex; align-items: center; } /** Dialog: Details */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { - line-height: 22px; + line-height: 20px; flex: 1; /* let the message always grow */ } @@ -167,12 +180,8 @@ align-items: center; padding-right: 1px; overflow: hidden; /* buttons row should never overflow */ -} - -.monaco-dialog-box > .dialog-buttons-row { - display: flex; white-space: nowrap; - padding: 20px 10px 10px; + padding: 20px 0px 0px; } /** Dialog: Buttons */ @@ -196,8 +205,8 @@ .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button { overflow: hidden; text-overflow: ellipsis; - margin: 4px 5px; /* allows button focus outline to be visible */ - outline-offset: 2px !important; + margin: 4px; /* allows button focus outline to be visible */ + outline-offset: 1px !important; } .monaco-dialog-box.align-vertical > .dialog-buttons-row > .dialog-buttons > .monaco-button { @@ -238,3 +247,7 @@ .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-dropdown-button { padding: 0 4px; } + +.monaco-dialog-modal-block .dialog-shadow { + border-radius: var(--vscode-cornerRadius-xLarge); +} diff --git a/src/vs/base/browser/ui/dropdown/dropdown.css b/src/vs/base/browser/ui/dropdown/dropdown.css index bfcaee41f98ad..7c70f376b1491 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.css +++ b/src/vs/base/browser/ui/dropdown/dropdown.css @@ -20,6 +20,11 @@ cursor: default; } +.monaco-dropdown .dropdown-menu { + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); +} + .monaco-dropdown-with-primary { display: flex !important; flex-direction: row; diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index 85379221cf22a..c2837659d5fb1 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -139,6 +139,7 @@ .monaco-hover .hover-row.status-bar .actions .action-container .action .icon { padding-right: 4px; vertical-align: middle; + font-size: inherit; } .monaco-hover .hover-row.status-bar .actions .action-container a { diff --git a/src/vs/base/browser/ui/iconLabel/iconLabel.ts b/src/vs/base/browser/ui/iconLabel/iconLabel.ts index 468667aabc013..034c650442a04 100644 --- a/src/vs/base/browser/ui/iconLabel/iconLabel.ts +++ b/src/vs/base/browser/ui/iconLabel/iconLabel.ts @@ -9,7 +9,7 @@ import * as css from '../../cssValue.js'; import { HighlightedLabel } from '../highlightedlabel/highlightedLabel.js'; import { IHoverDelegate } from '../hover/hoverDelegate.js'; import { IMatch } from '../../../common/filters.js'; -import { Disposable, IDisposable } from '../../../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js'; import { equals } from '../../../common/objects.js'; import { Range } from '../../../common/range.js'; import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js'; @@ -340,6 +340,7 @@ class LabelWithHighlights extends Disposable { private label: string | string[] | undefined = undefined; private singleLabel: HighlightedLabel | undefined = undefined; private options: IIconLabelValueOptions | undefined; + private readonly _labelDisposables = this._register(new DisposableStore()); constructor(private container: HTMLElement, private supportIcons: boolean) { super(); @@ -358,13 +359,15 @@ class LabelWithHighlights extends Disposable { if (typeof label === 'string') { if (!this.singleLabel) { + this._labelDisposables.clear(); this.container.textContent = ''; this.container.classList.remove('multiple'); - this.singleLabel = this._register(new HighlightedLabel(dom.append(this.container, dom.$('a.label-name', { id: options?.domId })))); + this.singleLabel = this._labelDisposables.add(new HighlightedLabel(dom.append(this.container, dom.$('a.label-name', { id: options?.domId })))); } this.singleLabel.set(label, options?.matches, undefined, options?.labelEscapeNewLines, supportIcons); } else { + this._labelDisposables.clear(); this.container.textContent = ''; this.container.classList.add('multiple'); this.singleLabel = undefined; @@ -378,7 +381,7 @@ class LabelWithHighlights extends Disposable { const id = options?.domId && `${options?.domId}_${i}`; const name = dom.$('a.label-name', { id, 'data-icon-label-count': label.length, 'data-icon-label-index': i, 'role': 'treeitem' }); - const highlightedLabel = this._register(new HighlightedLabel(dom.append(this.container, name))); + const highlightedLabel = this._labelDisposables.add(new HighlightedLabel(dom.append(this.container, name))); highlightedLabel.set(l, m, undefined, options?.labelEscapeNewLines, supportIcons); if (i < label.length - 1) { diff --git a/src/vs/base/browser/ui/inputbox/inputBox.css b/src/vs/base/browser/ui/inputbox/inputBox.css index 827a19f29b487..dc5e637f6ee56 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.css +++ b/src/vs/base/browser/ui/inputbox/inputBox.css @@ -103,4 +103,5 @@ background-repeat: no-repeat; width: 16px; height: 16px; + color: var(--vscode-icon-foreground); } diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 6e29b67c503a5..5c62e99faf886 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -152,8 +152,8 @@ export class ExternalElementsDragAndDropData implements IDragAndDropData { export class NativeDragAndDropData implements IDragAndDropData { - readonly types: any[]; - readonly files: any[]; + readonly types: unknown[]; + readonly files: unknown[]; constructor() { this.types = []; diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index c747ea1cd87da..bbadbf1d73b4c 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -321,14 +321,12 @@ export class Menu extends ActionBar { const fgColor = style.foregroundColor ?? ''; const bgColor = style.backgroundColor ?? ''; const border = style.borderColor ? `1px solid ${style.borderColor}` : ''; - const borderRadius = '5px'; - const shadow = style.shadowColor ? `0 2px 8px ${style.shadowColor}` : ''; + const borderRadius = 'var(--vscode-cornerRadius-large)'; scrollElement.style.outline = border; scrollElement.style.borderRadius = borderRadius; scrollElement.style.color = fgColor; scrollElement.style.backgroundColor = bgColor; - scrollElement.style.boxShadow = shadow; } override getContainer(): HTMLElement { @@ -1019,10 +1017,12 @@ export function formatRule(c: ThemeIcon) { } export function getMenuWidgetCSS(style: IMenuStyles, isForShadowDom: boolean): string { + const borderColor = style.borderColor ?? 'var(--vscode-menu-border)'; let result = /* css */` .monaco-menu { font-size: 13px; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-large); + border: 1px solid ${borderColor}; min-width: 160px; } @@ -1137,11 +1137,11 @@ ${formatRule(Codicon.menuSubmenu)} .monaco-menu .monaco-action-bar.vertical .action-menu-item { flex: 1 1 auto; display: flex; - height: 2em; + height: 24px; align-items: center; position: relative; margin: 0 4px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); } .monaco-menu .monaco-action-bar.vertical .action-menu-item:hover .keybinding, @@ -1241,6 +1241,9 @@ ${formatRule(Codicon.menuSubmenu)} border: none; animation: fadeIn 0.083s linear; -webkit-app-region: no-drag; + box-shadow: var(--vscode-shadow-lg${style.shadowColor ? `, 0 0 12px ${style.shadowColor}` : ''}); + border-radius: var(--vscode-cornerRadius-large); + overflow: hidden; } .context-view.monaco-menu-container :focus, @@ -1270,7 +1273,7 @@ ${formatRule(Codicon.menuSubmenu)} } .monaco-menu .monaco-action-bar.vertical .action-menu-item { - height: 2em; + height: 24px; } .monaco-menu .monaco-action-bar.vertical .action-label:not(.separator), diff --git a/src/vs/base/browser/ui/selectBox/selectBox.ts b/src/vs/base/browser/ui/selectBox/selectBox.ts index 335c2c9c09bdc..e70edcbac5f57 100644 --- a/src/vs/base/browser/ui/selectBox/selectBox.ts +++ b/src/vs/base/browser/ui/selectBox/selectBox.ts @@ -51,11 +51,13 @@ export interface ISelectOptionItem { descriptionIsMarkdown?: boolean; readonly descriptionMarkdownActionHandler?: MarkdownActionHandler; isDisabled?: boolean; + isSeparator?: boolean; } export const SeparatorSelectOption: Readonly = Object.freeze({ text: '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', isDisabled: true, + isSeparator: true, }); export interface ISelectBoxStyles extends IListStyles { diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css index b2665393270ab..769ba3a08aa26 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css @@ -6,8 +6,8 @@ .monaco-select-box-dropdown-container { display: none; box-sizing: border-box; - border-radius: var(--vscode-cornerRadius-small); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-select-box-dropdown-container > .select-box-details-pane > .select-box-description-markdown * { @@ -45,6 +45,11 @@ padding: 5px 6px; } +/* Remove list-level focus ring — individual rows show their own focus indicators */ +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list:focus::before { + outline: 0 !important; +} + .monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row { cursor: pointer; padding-left: 2px; @@ -76,6 +81,38 @@ } +/* Separator styling */ +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator { + cursor: default; + border-radius: 0; + padding: 0; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-text { + visibility: hidden; + width: 0; + float: none; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-detail { + display: none; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator > .option-decorator-right { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-separator::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + background-color: var(--vscode-menu-separatorBackground); +} + /* Accepted CSS hiding technique for accessibility reader text */ /* https://webaim.org/techniques/css/invisiblecontent/ */ diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index f6c2ff1cb4fec..b7cbca1525069 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -71,6 +71,14 @@ class SelectListRenderer implements IListRenderer .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { background-color: ${this.styles.listHoverBackground} !important; }`); } - // Match quick input outline styles - ignore for disabled options + // Match action widget outline styles - ignore for disabled options if (this.styles.listFocusOutline) { - content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1.6px dotted ${this.styles.listFocusOutline} !important; outline-offset: -1.6px !important; }`); + content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1px solid ${this.styles.listFocusOutline} !important; outline-offset: -1px !important; }`); } if (this.styles.listHoverOutline) { - content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1.6px dashed ${this.styles.listHoverOutline} !important; outline-offset: -1.6px !important; }`); + content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1px solid ${this.styles.listHoverOutline} !important; outline-offset: -1px !important; }`); } // Clear list styles on focus and on hover for disabled options @@ -425,11 +433,9 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi const background = this.styles.selectBackground ?? ''; const listBackground = cssJs.asCssValueWithDefault(this.styles.selectListBackground, background); + this.selectDropDownContainer.style.backgroundColor = listBackground; this.selectDropDownListContainer.style.backgroundColor = listBackground; this.selectionDetailsPane.style.backgroundColor = listBackground; - const optionsBorder = this.styles.focusBorder ?? ''; - this.selectDropDownContainer.style.outlineColor = optionsBorder; - this.selectDropDownContainer.style.outlineOffset = '-1px'; this.selectList.style(this.styles); } @@ -510,6 +516,12 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi private renderSelectDropDown(container: HTMLElement, preLayoutPosition?: boolean): IDisposable { container.appendChild(this.selectDropDownContainer); + // Inherit font-size from the select button so the dropdown matches + const computedFontSize = dom.getWindow(this.selectElement).getComputedStyle(this.selectElement).fontSize; + if (computedFontSize) { + this.selectDropDownContainer.style.fontSize = computedFontSize; + } + // Pre-Layout allows us to change position this.layoutSelectDropDown(preLayoutPosition); @@ -727,6 +739,10 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi mouseSupport: false, accessibilityProvider: { getAriaLabel: element => { + if (element.isSeparator) { + return localize('selectBoxSeparator', "separator"); + } + let label = element.text; if (element.detail) { label += `. ${element.detail}`; @@ -772,7 +788,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // SetUp list mouse controller - control navigation, disabled items, focus this._register(dom.addDisposableListener(this.selectList.getHTMLElement(), dom.EventType.POINTER_UP, e => this.onPointerUp(e))); - this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && this.selectList.setFocus([e.index]))); + this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && !this.options[e.index]?.isDisabled && this.selectList.setFocus([e.index]))); this._register(this.selectList.onDidChangeFocus(e => this.onListFocus(e))); this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.FOCUS_OUT, e => { @@ -932,6 +948,12 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi private onEnter(e: StandardKeyboardEvent): void { dom.EventHelper.stop(e); + // Ignore if current selection is disabled (e.g. separator) + if (this.options[this.selected]?.isDisabled) { + this.hideSelectDropDown(true); + return; + } + // Only fire if selection change if (this.selected !== this._currentSelection) { this._currentSelection = this.selected; @@ -947,22 +969,23 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.hideSelectDropDown(true); } - // List navigation - have to handle a disabled option (jump over) + // List navigation - have to handle disabled options (jump over) private onDownArrow(e: StandardKeyboardEvent): void { if (this.selected < this.options.length - 1) { dom.EventHelper.stop(e, true); - // Skip disabled options - const nextOptionDisabled = this.options[this.selected + 1].isDisabled; + // Skip over all contiguous disabled options + let next = this.selected + 1; + while (next < this.options.length && this.options[next].isDisabled) { + next++; + } - if (nextOptionDisabled && this.options.length > this.selected + 2) { - this.selected += 2; - } else if (nextOptionDisabled) { + if (next >= this.options.length) { return; - } else { - this.selected++; } + this.selected = next; + // Set focus/selection - only fire event when closing drop-down or on blur this.select(this.selected); this.selectList.setFocus([this.selected]); @@ -973,13 +996,19 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi private onUpArrow(e: StandardKeyboardEvent): void { if (this.selected > 0) { dom.EventHelper.stop(e, true); - // Skip disabled options - const previousOptionDisabled = this.options[this.selected - 1].isDisabled; - if (previousOptionDisabled && this.selected > 1) { - this.selected -= 2; - } else { - this.selected--; + + // Skip over all contiguous disabled options + let prev = this.selected - 1; + while (prev >= 0 && this.options[prev].isDisabled) { + prev--; + } + + if (prev < 0) { + return; } + + this.selected = prev; + // Set focus/selection - only fire event when closing drop-down or on blur this.select(this.selected); this.selectList.setFocus([this.selected]); @@ -994,13 +1023,17 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Allow scrolling to settle setTimeout(() => { - this.selected = this.selectList.getFocus()[0]; + let candidate = this.selectList.getFocus()[0]; - // Shift selection down if we land on a disabled option - if (this.options[this.selected].isDisabled && this.selected < this.options.length - 1) { - this.selected++; - this.selectList.setFocus([this.selected]); + // Shift selection up if we land on a disabled option + while (candidate > 0 && this.options[candidate].isDisabled) { + candidate--; + } + if (this.options[candidate].isDisabled) { + return; } + this.selected = candidate; + this.selectList.setFocus([this.selected]); this.selectList.reveal(this.selected); this.select(this.selected); }, 1); @@ -1013,13 +1046,17 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Allow scrolling to settle setTimeout(() => { - this.selected = this.selectList.getFocus()[0]; + let candidate = this.selectList.getFocus()[0]; - // Shift selection up if we land on a disabled option - if (this.options[this.selected].isDisabled && this.selected > 0) { - this.selected--; - this.selectList.setFocus([this.selected]); + // Shift selection down if we land on a disabled option + while (candidate < this.options.length - 1 && this.options[candidate].isDisabled) { + candidate++; } + if (this.options[candidate].isDisabled) { + return; + } + this.selected = candidate; + this.selectList.setFocus([this.selected]); this.selectList.reveal(this.selected); this.select(this.selected); }, 1); @@ -1031,10 +1068,14 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi if (this.options.length < 2) { return; } - this.selected = 0; - if (this.options[this.selected].isDisabled && this.selected > 1) { - this.selected++; + let candidate = 0; + while (candidate < this.options.length - 1 && this.options[candidate].isDisabled) { + candidate++; } + if (this.options[candidate].isDisabled) { + return; + } + this.selected = candidate; this.selectList.setFocus([this.selected]); this.selectList.reveal(this.selected); this.select(this.selected); @@ -1046,10 +1087,14 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi if (this.options.length < 2) { return; } - this.selected = this.options.length - 1; - if (this.options[this.selected].isDisabled && this.selected > 1) { - this.selected--; + let candidate = this.options.length - 1; + while (candidate > 0 && this.options[candidate].isDisabled) { + candidate--; + } + if (this.options[candidate].isDisabled) { + return; } + this.selected = candidate; this.selectList.setFocus([this.selected]); this.selectList.reveal(this.selected); this.select(this.selected); diff --git a/src/vs/base/browser/ui/selectBox/selectBoxNative.ts b/src/vs/base/browser/ui/selectBox/selectBoxNative.ts index 9eebae7dbb138..0c7ac5bcb35a3 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxNative.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxNative.ts @@ -98,7 +98,7 @@ export class SelectBoxNative extends Disposable implements ISelectBoxDelegate { this.selectElement.options.length = 0; this.options.forEach((option, index) => { - this.selectElement.add(this.createOption(option.text, index, option.isDisabled)); + this.selectElement.add(this.createOption(option.text, index, option.isDisabled, option.isSeparator)); }); } @@ -179,11 +179,15 @@ export class SelectBoxNative extends Disposable implements ISelectBoxDelegate { } - private createOption(value: string, index: number, disabled?: boolean): HTMLOptionElement { + private createOption(value: string, index: number, disabled?: boolean, isSeparator?: boolean): HTMLOptionElement { const option = document.createElement('option'); option.value = value; option.text = value; - option.disabled = !!disabled; + option.disabled = !!disabled || !!isSeparator; + + if (isSeparator) { + option.setAttribute('role', 'separator'); + } return option; } diff --git a/src/vs/base/browser/ui/toolbar/toolbar.ts b/src/vs/base/browser/ui/toolbar/toolbar.ts index 21696911bd1a6..73e90474f665d 100644 --- a/src/vs/base/browser/ui/toolbar/toolbar.ts +++ b/src/vs/base/browser/ui/toolbar/toolbar.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IContextMenuProvider } from '../../contextmenu.js'; +import * as DOM from '../../dom.js'; import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { AnchorAlignment } from '../contextview/contextview.js'; import { DropdownMenuActionViewItem } from '../dropdown/dropdownActionViewItem.js'; @@ -23,6 +24,14 @@ const ACTION_PADDING = 4; /* 4px padding */ const ACTION_MIN_WIDTH_VAR = '--vscode-toolbar-action-min-width'; +export interface IToolBarResponsiveBehaviorOptions { + readonly enabled: boolean; + readonly kind: 'last' | 'all'; + readonly minItems?: number; + readonly actionMinWidth?: number; + readonly getActionMinWidth?: (action: IAction) => number | undefined; +} + export interface IToolBarOptions { orientation?: ActionsOrientation; actionViewItemProvider?: IActionViewItemProvider; @@ -59,8 +68,9 @@ export interface IToolBarOptions { * - `kind`: The kind of responsive behavior to apply. Can be either `last` to only shrink the last item, or `all` to shrink all items equally. * - `minItems`: The minimum number of items that should always be visible. * - `actionMinWidth`: The minimum width of each action item. Defaults to `ACTION_MIN_WIDTH` (24px). + * - `getActionMinWidth`: Optional per-action minimum width override in pixels. */ - responsiveBehavior?: { enabled: boolean; kind: 'last' | 'all'; minItems?: number; actionMinWidth?: number }; + responsiveBehavior?: IToolBarResponsiveBehaviorOptions; } /** @@ -81,7 +91,6 @@ export class ToolBar extends Disposable { private originalSecondaryActions: ReadonlyArray = []; private hiddenActions: { action: IAction; size: number }[] = []; private readonly disposables = this._register(new DisposableStore()); - private readonly actionMinWidth: number; constructor(private readonly container: HTMLElement, contextMenuProvider: IContextMenuProvider, options: IToolBarOptions = { orientation: ActionsOrientation.HORIZONTAL }) { super(); @@ -161,15 +170,12 @@ export class ToolBar extends Disposable { } })); - // Store effective action min width - this.actionMinWidth = (options.responsiveBehavior?.actionMinWidth ?? ACTION_MIN_WIDTH) + ACTION_PADDING; - // Responsive support if (this.options.responsiveBehavior?.enabled) { this.element.classList.toggle('responsive', true); this.element.classList.toggle('responsive-all', this.options.responsiveBehavior.kind === 'all'); this.element.classList.toggle('responsive-last', this.options.responsiveBehavior.kind === 'last'); - this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, `${this.actionMinWidth - ACTION_PADDING}px`); + this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, `${this.getConfiguredActionMinWidth()}px`); const observer = new ResizeObserver(() => { this.updateActions(this.element.getBoundingClientRect().width); @@ -227,6 +233,18 @@ export class ToolBar extends Disposable { this.actionBar.setAriaLabel(label); } + /** + * Force the responsive overflow logic to re-evaluate item visibility. + * Call this after action view items change their rendered size externally + * (e.g. label text changes) without the toolbar being notified. + */ + relayout(): void { + if (this.options.responsiveBehavior?.enabled) { + const width = this.element.getBoundingClientRect().width; + this.updateActions(width); + } + } + setActions(primaryActions: ReadonlyArray, secondaryActions?: ReadonlyArray): void { this.clear(); @@ -251,7 +269,8 @@ export class ToolBar extends Disposable { this.actionBar.push(action, { icon: this.options.icon ?? true, label: this.options.label ?? false, keybinding: this.getKeybindingLabel(action) }); }); - this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction)); + this.updateOverflowClassName(); + this.applyResponsiveActionMinWidths(); if (this.options.responsiveBehavior?.enabled) { // Reset hidden actions @@ -260,6 +279,9 @@ export class ToolBar extends Disposable { // Set the minimum width if (this.options.responsiveBehavior?.minItems !== undefined) { const itemCount = this.options.responsiveBehavior.minItems; + const primaryActionsMinWidth = this.originalPrimaryActions + .slice(0, itemCount) + .reduce((total, action) => total + this.getActionMinWidth(action), 0); // Account for overflow menu let overflowWidth = 0; @@ -270,11 +292,12 @@ export class ToolBar extends Disposable { overflowWidth = ACTION_MIN_WIDTH + ACTION_PADDING; } - this.container.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`; - this.element.style.minWidth = `${itemCount * this.actionMinWidth + overflowWidth}px`; + this.container.style.minWidth = `${primaryActionsMinWidth + overflowWidth}px`; + this.element.style.minWidth = `${primaryActionsMinWidth + overflowWidth}px`; } else { - this.container.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`; - this.element.style.minWidth = `${ACTION_MIN_WIDTH + ACTION_PADDING}px`; + const minimumActionWidth = this.originalPrimaryActions.length > 0 ? this.getActionMinWidth(this.originalPrimaryActions[0]) : ACTION_MIN_WIDTH + ACTION_PADDING; + this.container.style.minWidth = `${minimumActionWidth}px`; + this.element.style.minWidth = `${minimumActionWidth}px`; } // Update toolbar actions to fit with container width @@ -292,15 +315,67 @@ export class ToolBar extends Disposable { return key?.getLabel() ?? undefined; } + private getConfiguredActionMinWidth(action?: IAction): number { + if (action?.id === ToggleMenuAction.ID) { + return ACTION_MIN_WIDTH; + } + + return this.options.responsiveBehavior?.getActionMinWidth?.(action ?? this.toggleMenuAction) + ?? this.options.responsiveBehavior?.actionMinWidth + ?? ACTION_MIN_WIDTH; + } + + private getActionMinWidth(action?: IAction): number { + return this.getConfiguredActionMinWidth(action) + ACTION_PADDING; + } + + private applyResponsiveActionMinWidths(): void { + if (!this.options.responsiveBehavior?.enabled) { + return; + } + + if (this.options.responsiveBehavior.kind === 'last') { + const hasToggleMenuAction = this.actionBar.hasAction(this.toggleMenuAction); + const shrinkableIndex = hasToggleMenuAction ? this.actionBar.length() - 2 : this.actionBar.length() - 1; + const shrinkableAction = shrinkableIndex >= 0 ? this.actionBar.getAction(shrinkableIndex) : undefined; + const minWidth = `${this.getConfiguredActionMinWidth(shrinkableAction)}px`; + if (this.element.style.getPropertyValue(ACTION_MIN_WIDTH_VAR) !== minWidth) { + this.element.style.setProperty(ACTION_MIN_WIDTH_VAR, minWidth); + } + return; + } + + const actionsContainer = this.actionBar.getContainer().firstElementChild; + if (!DOM.isHTMLElement(actionsContainer)) { + return; + } + + for (let i = 0; i < actionsContainer.children.length; i++) { + const actionItem = actionsContainer.children.item(i); + if (!DOM.isHTMLElement(actionItem)) { + continue; + } + + const action = this.actionBar.getAction(i); + const minWidth = `${this.getConfiguredActionMinWidth(action)}px`; + if (actionItem.style.minWidth !== minWidth) { + actionItem.style.minWidth = minWidth; + } + } + } + private updateActions(containerWidth: number) { // Actions bar is empty if (this.actionBar.isEmpty()) { return; } + this.applyResponsiveActionMinWidths(); + // Ensure that the container width respects the minimum width of the // element which is set based on the `responsiveBehavior.minItems` option - containerWidth = Math.max(containerWidth, parseInt(this.element.style.minWidth)); + const parsedMinWidth = parseInt(this.element.style.minWidth); + containerWidth = Math.max(containerWidth, Number.isNaN(parsedMinWidth) ? 0 : parsedMinWidth); // Each action is assumed to have a minimum width so that actions with a label // can shrink to the action's minimum width. We do this so that action visibility @@ -311,27 +386,37 @@ export class ToolBar extends Disposable { const primaryActionsCount = hasToggleMenuAction ? this.actionBar.length() - 1 : this.actionBar.length(); + if (primaryActionsCount === 0) { + return hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; + } let itemsWidth = 0; for (let i = 0; i < primaryActionsCount - 1; i++) { itemsWidth += this.actionBar.getWidth(i) + ACTION_PADDING; } - itemsWidth += actualWidth ? this.actionBar.getWidth(primaryActionsCount - 1) : this.actionMinWidth; // item to shrink + const action = this.actionBar.getAction(primaryActionsCount - 1); + itemsWidth += actualWidth ? this.actionBar.getWidth(primaryActionsCount - 1) : this.getActionMinWidth(action); // item to shrink itemsWidth += hasToggleMenuAction ? ACTION_MIN_WIDTH + ACTION_PADDING : 0; // toggle menu action return itemsWidth; } else { - return this.actionBar.length() * this.actionMinWidth; + let itemsWidth = 0; + for (let i = 0; i < this.actionBar.length(); i++) { + itemsWidth += actualWidth ? this.actionBar.getWidth(i) : this.getActionMinWidth(this.actionBar.getAction(i)); + } + return itemsWidth; } }; + const minimumWidth = actionBarWidth(false); + // Action bar fits and there are no hidden actions to show - if (actionBarWidth(false) <= containerWidth && this.hiddenActions.length === 0) { + if (minimumWidth <= containerWidth && this.hiddenActions.length === 0) { return; } - if (actionBarWidth(false) > containerWidth) { + if (minimumWidth > containerWidth) { // Check for max items limit if (this.options.responsiveBehavior?.minItems !== undefined) { const primaryActionsCount = this.actionBar.hasAction(this.toggleMenuAction) @@ -344,15 +429,15 @@ export class ToolBar extends Disposable { } // Hide actions from the right - while (actionBarWidth(true) > containerWidth && this.actionBar.length() > 0) { + while (actionBarWidth(false) > containerWidth && this.actionBar.length() > 0) { const index = this.originalPrimaryActions.length - this.hiddenActions.length - 1; if (index < 0) { break; } // Store the action and its size - const size = Math.min(this.actionMinWidth, this.getItemWidth(index)); const action = this.originalPrimaryActions[index]; + const size = Math.min(this.getActionMinWidth(action), this.getItemWidth(index)); this.hiddenActions.unshift({ action, size }); // Remove the action @@ -367,7 +452,10 @@ export class ToolBar extends Disposable { label: this.options.label ?? false, keybinding: this.getKeybindingLabel(this.toggleMenuAction), }); + this.updateOverflowClassName(); } + + this.applyResponsiveActionMinWidths(); } } else { // Show actions from the top of the toggle menu @@ -392,7 +480,10 @@ export class ToolBar extends Disposable { if (this.originalSecondaryActions.length === 0 && this.hiddenActions.length === 0) { this.toggleMenuAction.menuActions = []; this.actionBar.pull(this.actionBar.length() - 1); + this.updateOverflowClassName(); } + + this.applyResponsiveActionMinWidths(); } } @@ -403,6 +494,11 @@ export class ToolBar extends Disposable { this.toggleMenuAction.menuActions = Separator.join(hiddenActions, secondaryActions); } + this.updateOverflowClassName(); + this.applyResponsiveActionMinWidths(); + } + + private updateOverflowClassName(): void { this.actionBar.domNode.classList.toggle('has-overflow', this.actionBar.hasAction(this.toggleMenuAction)); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 9a06fa89094d5..8ed1bed215818 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -2729,7 +2729,7 @@ export abstract class AbstractTree implements IDisposable renderer.updateOptions(optionsUpdate); } - this.view.updateOptions(this._options); + this.view.updateOptions(optionsUpdate); this.findController?.updateOptions(optionsUpdate); this.updateStickyScroll(optionsUpdate); diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 6c604269ac51a..9d6d200f68f11 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate } from '../list/list.js'; +import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate, NotSelectableGroupIdType } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; import { ComposedTreeDelegate, TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions, IStickyScrollDelegate, AbstractTree } from './abstractTree.js'; @@ -1309,7 +1309,10 @@ export class AsyncDataTree implements IDisposable diffIdentityProvider: options.diffIdentityProvider && { getId(node: IAsyncDataTreeNode): { toString(): string } { return options.diffIdentityProvider!.getId(node.element as T); - } + }, + getGroupId: options.diffIdentityProvider!.getGroupId ? (node: IAsyncDataTreeNode): number | NotSelectableGroupIdType => { + return options.diffIdentityProvider!.getGroupId!(node.element as T); + } : undefined } }; diff --git a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts index e4adc8326767a..0bcaa01c42638 100644 --- a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IIdentityProvider } from '../list/list.js'; +import { IIdentityProvider, NotSelectableGroupIdType } from '../list/list.js'; import { getVisibleState, IIndexTreeModelSpliceOptions, isFilterResult } from './indexTreeModel.js'; import { IObjectTreeModel, IObjectTreeModelOptions, IObjectTreeModelSetChildrenOptions, ObjectTreeModel } from './objectTreeModel.js'; import { ICollapseStateChangeEvent, IObjectTreeElement, ITreeListSpliceData, ITreeModel, ITreeModelSpliceEvent, ITreeNode, TreeError, TreeFilterResult, TreeVisibility, WeakMapper } from './tree.js'; @@ -113,7 +113,10 @@ interface ICompressedObjectTreeModelOptions extends IObjectTreeM const wrapIdentityProvider = (base: IIdentityProvider): IIdentityProvider> => ({ getId(node) { return node.elements.map(e => base.getId(e).toString()).join('\0'); - } + }, + getGroupId: base.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return base.getGroupId!(node.elements[node.elements.length - 1]); + } : undefined }); // Exported only for test reasons, do not use directly @@ -380,7 +383,10 @@ function mapOptions(compressedNodeUnwrapper: CompressedNodeUnwra identityProvider: options.identityProvider && { getId(node: ICompressedTreeNode): { toString(): string } { return options.identityProvider!.getId(compressedNodeUnwrapper(node)); - } + }, + getGroupId: options.identityProvider!.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return options.identityProvider!.getGroupId!(compressedNodeUnwrapper(node)); + } : undefined }, sorter: options.sorter && { compare(node: ICompressedTreeNode, otherNode: ICompressedTreeNode): number { diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts index 6d3e3f2b3db58..18641db33f93e 100644 --- a/src/vs/base/common/actions.ts +++ b/src/vs/base/common/actions.ts @@ -220,6 +220,24 @@ export class Separator implements IAction { return out; } + /** + * Removes leading, trailing, and consecutive duplicate separators in-place and returns the actions. + */ + public static clean(actions: IAction[]): IAction[] { + while (actions.length > 0 && actions[0].id === Separator.ID) { + actions.shift(); + } + while (actions.length > 0 && actions[actions.length - 1].id === Separator.ID) { + actions.pop(); + } + for (let i = actions.length - 2; i >= 0; i--) { + if (actions[i].id === Separator.ID && actions[i + 1].id === Separator.ID) { + actions.splice(i + 1, 1); + } + } + return actions; + } + static readonly ID = 'vs.actions.separator'; readonly id: string = Separator.ID; diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index 3dcfa0c513087..e5aaa42487681 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -1098,15 +1098,15 @@ export class IntervalTimer implements IDisposable { } } -export class RunOnceScheduler implements IDisposable { +export class RunOnceScheduler any = () => any> implements IDisposable { - protected runner: ((...args: unknown[]) => void) | null; + protected runner: Runner | null; private timeoutToken: Timeout | undefined; private timeout: number; private timeoutHandler: () => void; - constructor(runner: (...args: any[]) => void, delay: number) { + constructor(runner: Runner, delay: number) { this.timeoutToken = undefined; this.runner = runner; this.timeout = delay; @@ -1246,7 +1246,7 @@ export class ProcessTimeRunOnceScheduler { } } -export class RunOnceWorker extends RunOnceScheduler { +export class RunOnceWorker extends RunOnceScheduler<(units: T[]) => void> { private units: T[] = []; diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index f541d4face8e6..65b434378708d 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -655,4 +655,6 @@ export const codiconsLibrary = { openai: register('openai', 0xec81), claude: register('claude', 0xec82), openInWindow: register('open-in-window', 0xec83), + newSession: register('new-session', 0xec84), + terminalSecure: register('terminal-secure', 0xec85), } as const; diff --git a/src/vs/base/common/decorators.ts b/src/vs/base/common/decorators.ts index 7510ffcec1f4f..74d2e56f51e12 100644 --- a/src/vs/base/common/decorators.ts +++ b/src/vs/base/common/decorators.ts @@ -45,7 +45,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri } const memoizeKey = `$memoize$${key}`; - descriptor[fnKey!] = function (...args: any[]) { + descriptor[fnKey!] = function (this: any, ...args: unknown[]) { if (!this.hasOwnProperty(memoizeKey)) { Object.defineProperty(this, memoizeKey, { configurable: false, @@ -54,8 +54,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri value: fn.apply(this, args) }); } - // eslint-disable-next-line local/code-no-any-casts - return (this as any)[memoizeKey]; + return this[memoizeKey]; }; } diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 2d10cedc84d9d..3c53a69862215 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -48,6 +48,11 @@ export interface IPolicyData { readonly mcpAccess?: 'allow_all' | 'registry_only'; } +export interface ICopilotTokenInfo { + readonly sn?: string; + readonly fcv1?: string; +} + export interface IDefaultAccountAuthenticationProvider { readonly id: string; readonly name: string; diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index de0fce1d4fdd0..929ed2f9e0329 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -11,6 +11,7 @@ import { createSingleCallFunction } from './functional.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from './lifecycle.js'; import { LinkedList } from './linkedList.js'; import { IObservable, IObservableWithChange, IObserver } from './observable.js'; +import { env } from './process.js'; import { StopWatch } from './stopwatch.js'; import { MicrotaskDelay } from './symbols.js'; @@ -31,6 +32,14 @@ const _enableSnapshotPotentialLeakWarning = false // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed ; + +const _bufferLeakWarnCountThreshold = 100; +const _bufferLeakWarnTimeThreshold = 60_000; // 1 minute + +function _isBufferLeakWarningEnabled(): boolean { + return !!env['VSCODE_DEV']; +} + /** * An event with zero or one parameters that can be subscribed to. The event is a function itself. */ @@ -490,6 +499,7 @@ export namespace Event { * returned event causes this utility to leak a listener on the original event. * * @param event The event source for the new event. + * @param debugName A name for this buffer, used in leak detection warnings. * @param flushAfterTimeout Determines whether to flush the buffer after a timeout immediately or after a * `setTimeout` when the first event listener is added. * @param _buffer Internal: A source event array used for tests. @@ -499,15 +509,46 @@ export namespace Event { * // Start accumulating events, when the first listener is attached, flush * // the event after a timeout such that multiple listeners attached before * // the timeout would receive the event - * this.onInstallExtension = Event.buffer(service.onInstallExtension, true); + * this.onInstallExtension = Event.buffer(service.onInstallExtension, 'onInstallExtension', true); * ``` */ - export function buffer(event: Event, flushAfterTimeout = false, _buffer: T[] = [], disposable?: DisposableStore): Event { + export function buffer(event: Event, debugName: string, flushAfterTimeout = false, _buffer: T[] = [], disposable?: DisposableStore): Event { let buffer: T[] | null = _buffer.slice(); + // Dev-only leak detection: track when buffer was created and warn + // if events accumulate without ever being consumed. + let bufferLeakWarningData: { stack: Stacktrace; timerId: ReturnType; warned: boolean } | undefined; + if (_isBufferLeakWarningEnabled()) { + bufferLeakWarningData = { + stack: Stacktrace.create(), + timerId: setTimeout(() => { + if (buffer && buffer.length > 0 && bufferLeakWarningData && !bufferLeakWarningData.warned) { + bufferLeakWarningData.warned = true; + console.warn(`[Event.buffer][${debugName}] potential LEAK detected: ${buffer.length} events buffered for ${_bufferLeakWarnTimeThreshold / 1000}s without being consumed. Buffered here:`); + bufferLeakWarningData.stack.print(); + } + }, _bufferLeakWarnTimeThreshold), + warned: false + }; + if (disposable) { + disposable.add(toDisposable(() => clearTimeout(bufferLeakWarningData!.timerId))); + } + } + + const clearLeakWarningTimer = () => { + if (bufferLeakWarningData) { + clearTimeout(bufferLeakWarningData.timerId); + } + }; + let listener: IDisposable | null = event(e => { if (buffer) { buffer.push(e); + if (_isBufferLeakWarningEnabled() && bufferLeakWarningData && !bufferLeakWarningData.warned && buffer.length >= _bufferLeakWarnCountThreshold) { + bufferLeakWarningData.warned = true; + console.warn(`[Event.buffer][${debugName}] potential LEAK detected: ${buffer.length} events buffered without being consumed. Buffered here:`); + bufferLeakWarningData.stack.print(); + } } else { emitter.fire(e); } @@ -520,6 +561,7 @@ export namespace Event { const flush = () => { buffer?.forEach(e => emitter.fire(e)); buffer = null; + clearLeakWarningTimer(); }; const emitter = new Emitter({ @@ -547,6 +589,7 @@ export namespace Event { listener.dispose(); } listener = null; + clearLeakWarningTimer(); } }); @@ -664,7 +707,7 @@ export namespace Event { * Creates an {@link Event} from a node event emitter. */ export function fromNodeEventEmitter(emitter: NodeEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { - const fn = (...args: any[]) => result.fire(map(...args)); + const fn = (...args: unknown[]) => result.fire(map(...args)); const onFirstListenerAdd = () => emitter.on(eventName, fn); const onLastListenerRemove = () => emitter.removeListener(eventName, fn); const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); @@ -681,7 +724,7 @@ export namespace Event { * Creates an {@link Event} from a DOM event emitter. */ export function fromDOMEventEmitter(emitter: DOMEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { - const fn = (...args: any[]) => result.fire(map(...args)); + const fn = (...args: unknown[]) => result.fire(map(...args)); const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn); const onLastListenerRemove = () => emitter.removeEventListener(eventName, fn); const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); @@ -986,7 +1029,8 @@ class LeakageMonitor { console.warn(message); console.warn(topStack); - const error = new ListenerLeakError(message, topStack); + const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerLeakError(kind, message, topStack); this._errorHandler(error); } @@ -1028,9 +1072,16 @@ class Stacktrace { // error that is logged when going over the configured listener threshold export class ListenerLeakError extends Error { - constructor(message: string, stack: string) { - super(message); + /** + * The detailed message including listener count and most frequent stack. + * Available locally for debugging but intentionally not used as the error + * `message` so that all leak errors group under the same title in telemetry. + */ + readonly details: string; + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind}`); this.name = 'ListenerLeakError'; + this.details = details; this.stack = stack; } } @@ -1038,9 +1089,16 @@ export class ListenerLeakError extends Error { // SEVERE error that is logged when having gone way over the configured listener // threshold so that the emitter refuses to accept more listeners export class ListenerRefusalError extends Error { - constructor(message: string, stack: string) { - super(message); + /** + * The detailed message including listener count and most frequent stack. + * Available locally for debugging but intentionally not used as the error + * `message` so that all leak errors group under the same title in telemetry. + */ + readonly details: string; + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind} (REFUSED to add)`); this.name = 'ListenerRefusalError'; + this.details = details; this.stack = stack; } } @@ -1178,7 +1236,8 @@ export class Emitter { console.warn(message); const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; - const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); const errorHandler = this._options?.onListenerError || onUnexpectedError; errorHandler(error); diff --git a/src/vs/base/common/htmlContent.ts b/src/vs/base/common/htmlContent.ts index 070279f045ae7..16049d7e6f731 100644 --- a/src/vs/base/common/htmlContent.ts +++ b/src/vs/base/common/htmlContent.ts @@ -210,9 +210,9 @@ export function createMarkdownLink(text: string, href: string, title?: string, e return `[${escapeTokens ? escapeMarkdownSyntaxTokens(text) : text}](${href}${title ? ` "${escapeMarkdownSyntaxTokens(title)}"` : ''})`; } -export function createMarkdownCommandLink(command: { title: string; id: string; arguments?: unknown[]; tooltip?: string }, escapeTokens = true): string { +export function createMarkdownCommandLink(command: { text: string; id: string; arguments?: unknown[]; tooltip: string }, escapeTokens = true): string { const uri = createCommandUri(command.id, ...(command.arguments || [])).toString(); - return createMarkdownLink(command.title, uri, command.tooltip, escapeTokens); + return createMarkdownLink(command.text, uri, command.tooltip, escapeTokens); } export function createCommandUri(commandId: string, ...commandArgs: unknown[]): URI { diff --git a/src/vs/base/common/jsonRpcProtocol.ts b/src/vs/base/common/jsonRpcProtocol.ts index 67c4ed4fc4d82..35d7144ba82bf 100644 --- a/src/vs/base/common/jsonRpcProtocol.ts +++ b/src/vs/base/common/jsonRpcProtocol.ts @@ -43,6 +43,7 @@ export interface IJsonRpcErrorResponse { } export type JsonRpcMessage = IJsonRpcRequest | IJsonRpcNotification | IJsonRpcSuccessResponse | IJsonRpcErrorResponse; +export type JsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; interface IPendingRequest { promise: DeferredPromise; @@ -122,15 +123,31 @@ export class JsonRpcProtocol extends Disposable { }) as Promise; } - public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { + /** + * Handles one or more incoming JSON-RPC messages. + * + * Returns an array of JSON-RPC response objects generated for any incoming + * requests in the message(s). Notifications and responses to our own + * outgoing requests do not produce return values. For batch inputs, the + * returned responses are in the same order as the corresponding requests. + * + * Note: responses are also emitted via the `_send` callback, so callers + * that rely on the return value should not re-send them. + */ + public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { if (Array.isArray(message)) { + const replies: JsonRpcResponse[] = []; for (const single of message) { - await this._handleMessage(single); + const reply = await this._handleMessage(single); + if (reply) { + replies.push(reply); + } } - return; + return replies; } - await this._handleMessage(message); + const reply = await this._handleMessage(message); + return reply ? [reply] : []; } public cancelPendingRequest(id: JsonRpcId): void { @@ -152,22 +169,25 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleMessage(message: JsonRpcMessage): Promise { + private async _handleMessage(message: JsonRpcMessage): Promise { if (isJsonRpcResponse(message)) { if (hasKey(message, { result: true })) { this._handleResult(message); } else { this._handleError(message); } + return undefined; } if (isJsonRpcRequest(message)) { - await this._handleRequest(message); + return this._handleRequest(message); } if (isJsonRpcNotification(message)) { this._handlers.handleNotification?.(message); } + + return undefined; } private _handleResult(response: IJsonRpcSuccessResponse): void { @@ -192,17 +212,18 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleRequest(request: IJsonRpcRequest): Promise { + private async _handleRequest(request: IJsonRpcRequest): Promise { if (!this._handlers.handleRequest) { - this._send({ + const response: IJsonRpcErrorResponse = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.MethodNotFound, message: `Method not found: ${request.method}`, } - }); - return; + }; + this._send(response); + return response; } const cts = new CancellationTokenSource(); @@ -211,14 +232,17 @@ export class JsonRpcProtocol extends Disposable { try { const resultOrThenable = this._handlers.handleRequest(request, cts.token); const result = isThenable(resultOrThenable) ? await resultOrThenable : resultOrThenable; - this._send({ + const response: IJsonRpcSuccessResponse = { jsonrpc: '2.0', id: request.id, result, - }); + }; + this._send(response); + return response; } catch (error) { + let response: IJsonRpcErrorResponse; if (error instanceof JsonRpcError) { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { @@ -226,17 +250,19 @@ export class JsonRpcProtocol extends Disposable { message: error.message, data: error.data, } - }); + }; } else { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.InternalError, message: error instanceof Error ? error.message : 'Internal error', } - }); + }; } + this._send(response); + return response; } finally { cts.dispose(true); } diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts index 630edb097f250..a75cbb1cce309 100644 --- a/src/vs/base/common/lifecycle.ts +++ b/src/vs/base/common/lifecycle.ts @@ -720,7 +720,7 @@ export class AsyncReferenceCollection { constructor(private referenceCollection: ReferenceCollection>) { } - async acquire(key: string, ...args: any[]): Promise> { + async acquire(key: string, ...args: unknown[]): Promise> { const ref = this.referenceCollection.acquire(key, ...args); try { diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 74cb106fd3cef..c2efd167054a8 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -75,6 +75,9 @@ export namespace Schemas { export const vscodeTerminal = 'vscode-terminal'; + /** Scheme used for the image carousel editor. */ + export const vscodeImageCarousel = 'vscode-image-carousel'; + /** Scheme used for code blocks in chat. */ export const vscodeChatCodeBlock = 'vscode-chat-code-block'; diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index af010d118c3b6..95347c3088b3e 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -7,7 +7,7 @@ export { observableValueOpts } from './observables/observableValueOpts.js'; export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta, autorunSelfDisposable } from './reactions/autorun.js'; -export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction } from './base.js'; +export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type IReaderWithStore, type ISettableObservable, type ITransaction } from './base.js'; export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; export { type IDerivedReader } from './observables/derivedImpl.js'; diff --git a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts index d19da1fe15970..c4d392bca69fd 100644 --- a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts +++ b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts @@ -72,7 +72,7 @@ export class SimpleTypedRpcConnection { const requests = new Proxy({}, { get: (target, key: string) => { - return async (...args: any[]) => { + return async (...args: unknown[]) => { const result = await this._channel.sendRequest([key, args] satisfies OutgoingMessage); if (result.type === 'error') { throw result.value; @@ -85,7 +85,7 @@ export class SimpleTypedRpcConnection { const notifications = new Proxy({}, { get: (target, key: string) => { - return (...args: any[]) => { + return (...args: unknown[]) => { this._channel.sendNotification([key, args] satisfies OutgoingMessage); }; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 8325d6789f089..126b180fa35ca 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -79,6 +79,7 @@ export interface IProductConfiguration { readonly win32VersionedUpdate?: boolean; readonly applicationName: string; readonly embedderIdentifier?: string; + readonly telemetryAppName?: string; readonly urlProtocol: string; readonly dataFolderName: string; // location for extensions (e.g. ~/.vscode-insiders) diff --git a/src/vs/base/common/resources.ts b/src/vs/base/common/resources.ts index 3b8370c160b17..a064a28773626 100644 --- a/src/vs/base/common/resources.ts +++ b/src/vs/base/common/resources.ts @@ -190,8 +190,8 @@ export class ExtUri implements IExtUri { return basename(resource) || resource.authority; } - basename(resource: URI): string { - return paths.posix.basename(resource.path); + basename(resource: URI, suffix?: string): string { + return paths.posix.basename(resource.path, suffix); } extname(resource: URI): string { diff --git a/src/vs/base/node/crypto.ts b/src/vs/base/node/crypto.ts index f1637f4057f48..dee5f05fb3fa7 100644 --- a/src/vs/base/node/crypto.ts +++ b/src/vs/base/node/crypto.ts @@ -16,6 +16,7 @@ export async function checksum(path: string, sha256hash: string | undefined): Pr const done = createSingleCallFunction((err?: Error, result?: string) => { input.removeAllListeners(); hash.removeAllListeners(); + input.destroy(); if (err) { reject(err); diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index 8e061681e5c51..663b7f541ee68 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -1118,7 +1118,7 @@ export namespace ProxyChannel { const mapEventNameToEvent = new Map>(); for (const key in handler) { if (propertyIsEvent(key)) { - mapEventNameToEvent.set(key, Event.buffer(handler[key] as Event, true, undefined, disposables)); + mapEventNameToEvent.set(key, Event.buffer(handler[key] as Event, key, true, undefined, disposables)); } } @@ -1137,7 +1137,7 @@ export namespace ProxyChannel { } if (propertyIsEvent(event)) { - mapEventNameToEvent.set(event, Event.buffer(handler[event] as Event, true, undefined, disposables)); + mapEventNameToEvent.set(event, Event.buffer(handler[event] as Event, event, true, undefined, disposables)); return mapEventNameToEvent.get(event) as Event; } @@ -1209,10 +1209,10 @@ export namespace ProxyChannel { } // Function - return async function (...args: any[]) { + return async function (...args: unknown[]) { // Add context if any - let methodArgs: any[]; + let methodArgs: unknown[]; if (options && !isUndefinedOrNull(options.context)) { methodArgs = [options.context, ...args]; } else { diff --git a/src/vs/base/parts/ipc/electron-main/ipcMain.ts b/src/vs/base/parts/ipc/electron-main/ipcMain.ts index 0137b8924eb47..267c15b7125b2 100644 --- a/src/vs/base/parts/ipc/electron-main/ipcMain.ts +++ b/src/vs/base/parts/ipc/electron-main/ipcMain.ts @@ -25,7 +25,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { // Remember the wrapped listener so that later we can // properly implement `removeListener`. - const wrappedListener = (event: electron.IpcMainEvent, ...args: any[]) => { + const wrappedListener = (event: electron.IpcMainEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { listener(event, ...args); } @@ -43,7 +43,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { * only the next time a message is sent to `channel`, after which it is removed. */ once(channel: string, listener: ipcMainListener): this { - electron.ipcMain.once(channel, (event: electron.IpcMainEvent, ...args: any[]) => { + electron.ipcMain.once(channel, (event: electron.IpcMainEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { listener(event, ...args); } @@ -69,7 +69,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { * provided to the renderer process. Please refer to #24427 for details. */ handle(channel: string, listener: (event: electron.IpcMainInvokeEvent, ...args: any[]) => Promise): this { - electron.ipcMain.handle(channel, (event: electron.IpcMainInvokeEvent, ...args: any[]) => { + electron.ipcMain.handle(channel, (event: electron.IpcMainInvokeEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { return listener(event, ...args); } diff --git a/src/vs/base/parts/request/common/request.ts b/src/vs/base/parts/request/common/request.ts index 1e2a8ead2fdb0..b649cff368f72 100644 --- a/src/vs/base/parts/request/common/request.ts +++ b/src/vs/base/parts/request/common/request.ts @@ -50,6 +50,11 @@ export interface IRequestOptions { * be supported in all implementations. */ disableCache?: boolean; + /** + * Identifies the call site making this request, used for telemetry. + * Use "NO_FETCH_TELEMETRY" to opt out of request telemetry. + */ + callSite: string; } export interface IRequestContext { diff --git a/src/vs/base/parts/request/test/electron-main/request.test.ts b/src/vs/base/parts/request/test/electron-main/request.test.ts index 895b5dc6899fd..1b51cbf42893b 100644 --- a/src/vs/base/parts/request/test/electron-main/request.test.ts +++ b/src/vs/base/parts/request/test/electron-main/request.test.ts @@ -58,7 +58,8 @@ suite('Request', () => { url: `http://127.0.0.1:${port}`, headers: { 'echo-header': 'echo-value' - } + }, + callSite: 'request.test.GET' }, CancellationToken.None); assert.strictEqual(context.res.statusCode, 200); assert.strictEqual(context.res.headers['content-type'], 'application/json'); @@ -74,6 +75,7 @@ suite('Request', () => { type: 'POST', url: `http://127.0.0.1:${port}/postpath`, data: 'Some data', + callSite: 'request.test.POST' }, CancellationToken.None); assert.strictEqual(context.res.statusCode, 200); assert.strictEqual(context.res.headers['content-type'], 'application/json'); @@ -91,6 +93,7 @@ suite('Request', () => { type: 'GET', url: `http://127.0.0.1:${port}/noreply`, timeout: 123, + callSite: 'request.test.timeout' }, CancellationToken.None); assert.fail('Should fail with timeout'); } catch (err) { @@ -106,6 +109,7 @@ suite('Request', () => { const res = request({ type: 'GET', url: `http://127.0.0.1:${port}/noreply`, + callSite: 'request.test.cancel' }, source.token); await new Promise(resolve => setTimeout(resolve, 100)); source.cancel(); diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index cdfbe914fa929..16373be210102 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -307,6 +307,36 @@ suite('MarkdownRenderer', () => { assert.strictEqual(result.innerHTML, `

text bar

`); }); + test('Should use decoded file path as title for file:// links', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()})`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, fileUri.fsPath); + }); + + test('Should include fragment in title for file:// links with line numbers', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()}#L42)`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, `${fileUri.fsPath}#L42`); + }); + + test('Should not override explicit title for file:// links', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()} "Go to definition")`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, 'Go to definition'); + }); + suite('PlaintextMarkdownRender', () => { test('test code, blockquote, heading, list, listitem, paragraph, table, tablerow, tablecell, strong, em, br, del, text are rendered plaintext', () => { diff --git a/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts new file mode 100644 index 0000000000000..43fcc7a1795a2 --- /dev/null +++ b/src/vs/base/test/browser/ui/toolbar/toolbar.test.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IContextMenuProvider } from '../../../../browser/contextmenu.js'; +import { ActionBar } from '../../../../browser/ui/actionbar/actionbar.js'; +import { BaseActionViewItem } from '../../../../browser/ui/actionbar/actionViewItems.js'; +import { ToggleMenuAction, ToolBar } from '../../../../browser/ui/toolbar/toolbar.js'; +import { Action, IAction } from '../../../../common/actions.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; + +class FixedWidthActionViewItem extends BaseActionViewItem { + + constructor(action: IAction, private readonly width: number) { + super(undefined, action); + } + + override render(container: HTMLElement): void { + super.render(container); + container.style.width = `${this.width}px`; + container.style.boxSizing = 'border-box'; + container.style.overflow = 'hidden'; + container.style.whiteSpace = 'nowrap'; + container.textContent = this.action.label; + } +} + +class TestToolBar extends ToolBar { + get actionBarForTest(): Pick { + return this.actionBar; + } +} + +const contextMenuProvider: IContextMenuProvider = { + showContextMenu: () => { } +}; + +suite('ToolBar', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + container.style.width = '273px'; + document.body.appendChild(container); + }); + + teardown(() => { + container.remove(); + }); + + test('keeps the last primary action shrinkable when overflow is inserted', () => { + const widths = new Map([ + ['workbench.action.chat.attachContext', 22], + ['workbench.action.chat.openModePicker', 75], + ['workbench.action.chat.openModelPicker', 271], + ['workbench.action.chat.configureTools', 22], + [ToggleMenuAction.ID, 22], + ]); + + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 1, + actionMinWidth: 22, + }, + actionViewItemProvider: action => { + const width = widths.get(action.id); + return typeof width === 'number' ? new FixedWidthActionViewItem(action, width) : undefined; + } + })); + const actionBar = toolbar.actionBarForTest; + const originalGetWidth = actionBar.getWidth.bind(actionBar); + actionBar.getWidth = (index: number) => { + const action = actionBar.getAction(index); + return action ? (widths.get(action.id) ?? originalGetWidth(index)) : originalGetWidth(index); + }; + + const originalGetBoundingClientRect = toolbar.getElement().getBoundingClientRect.bind(toolbar.getElement()); + (toolbar.getElement() as HTMLElement & { getBoundingClientRect(): DOMRect }).getBoundingClientRect = () => ({ + ...originalGetBoundingClientRect(), + width: 273, + right: 273, + left: 0, + x: 0, + y: 0, + top: 0, + bottom: 0, + height: 0, + toJSON() { + return {}; + } + }); + + const actions = [ + store.add(new Action('workbench.action.chat.attachContext', 'Add Context...')), + store.add(new Action('workbench.action.chat.openModePicker', 'Open Agent Picker')), + store.add(new Action('workbench.action.chat.openModelPicker', 'Open Model Picker')), + store.add(new Action('workbench.action.chat.configureTools', 'Configure Tools...')), + ]; + + toolbar.setActions(actions); + + assert.strictEqual(toolbar.getItemsLength(), 4); + assert.strictEqual(toolbar.getItemAction(0)?.id, 'workbench.action.chat.attachContext'); + assert.strictEqual(toolbar.getItemAction(1)?.id, 'workbench.action.chat.openModePicker'); + assert.strictEqual(toolbar.getItemAction(2)?.id, 'workbench.action.chat.openModelPicker'); + assert.strictEqual(toolbar.getItemAction(3)?.id, ToggleMenuAction.ID); + assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + }); + + test('applies per-action responsive min widths', () => { + const toolbar = store.add(new ToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 1, + actionMinWidth: 22, + getActionMinWidth: action => action.id === 'workbench.action.chat.openModelPicker' ? 28 : undefined, + }, + actionViewItemProvider: action => new FixedWidthActionViewItem(action, 22) + })); + + const actions = [ + store.add(new Action('workbench.action.chat.attachContext', 'Add Context...')), + store.add(new Action('workbench.action.chat.openModePicker', 'Open Agent Picker')), + store.add(new Action('workbench.action.chat.openModelPicker', 'Open Model Picker')), + ]; + + toolbar.setActions(actions); + + assert.strictEqual(toolbar.getElement().style.getPropertyValue('--vscode-toolbar-action-min-width'), '28px'); + }); + + test('relayout re-evaluates responsive overflow after action width changes', () => { + const widths = new Map([ + ['workbench.action.chat.attachContext', 22], + ['workbench.action.chat.openModePicker', 22], + ['workbench.action.chat.openModelPicker', 50], + [ToggleMenuAction.ID, 22], + ]); + + const toolbar = store.add(new TestToolBar(container, contextMenuProvider, { + responsiveBehavior: { + enabled: true, + kind: 'last', + minItems: 1, + actionMinWidth: 22, + }, + actionViewItemProvider: action => { + const width = widths.get(action.id); + return typeof width === 'number' ? new FixedWidthActionViewItem(action, width) : undefined; + } + })); + const actionBar = toolbar.actionBarForTest; + const originalGetWidth = actionBar.getWidth.bind(actionBar); + actionBar.getWidth = (index: number) => { + const action = actionBar.getAction(index); + return action ? (widths.get(action.id) ?? originalGetWidth(index)) : originalGetWidth(index); + }; + + const originalGetBoundingClientRect = toolbar.getElement().getBoundingClientRect.bind(toolbar.getElement()); + (toolbar.getElement() as HTMLElement & { getBoundingClientRect(): DOMRect }).getBoundingClientRect = () => ({ + ...originalGetBoundingClientRect(), + width: 110, + right: 110, + left: 0, + x: 0, + y: 0, + top: 0, + bottom: 0, + height: 0, + toJSON() { + return {}; + } + }); + + const actions = [ + store.add(new Action('workbench.action.chat.attachContext', 'Add Context...')), + store.add(new Action('workbench.action.chat.openModePicker', 'Open Mode Picker')), + store.add(new Action('workbench.action.chat.openModelPicker', 'Open Model Picker')), + ]; + + toolbar.setActions(actions); + + assert.strictEqual(toolbar.getItemsLength(), 3); + assert.strictEqual(toolbar.getItemAction(2)?.id, 'workbench.action.chat.openModelPicker'); + assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), false); + + widths.set('workbench.action.chat.openModePicker', 80); + toolbar.relayout(); + + assert.strictEqual(toolbar.getItemsLength(), 3); + assert.strictEqual(toolbar.getItemAction(0)?.id, 'workbench.action.chat.attachContext'); + assert.strictEqual(toolbar.getItemAction(1)?.id, 'workbench.action.chat.openModePicker'); + assert.strictEqual(toolbar.getItemAction(2)?.id, ToggleMenuAction.ID); + assert.strictEqual(toolbar.getElement().querySelector('.monaco-action-bar')?.classList.contains('has-overflow'), true); + }); +}); diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index aa11fbe6036c4..8902791afcec5 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from '../../../../browser/ui/ import { ICompressedTreeNode } from '../../../../browser/ui/tree/compressedObjectTreeModel.js'; import { CompressibleObjectTree, ICompressibleTreeRenderer, ObjectTree } from '../../../../browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeRenderer } from '../../../../browser/ui/tree/tree.js'; +import { runWithFakedTimers } from '../../../common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; function getRowsTextContent(container: HTMLElement): string[] { @@ -16,6 +17,17 @@ function getRowsTextContent(container: HTMLElement): string[] { return rows.map(row => row.querySelector('.monaco-tl-contents')!.textContent!); } +function clickElement(element: HTMLElement, ctrlKey = false): void { + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, ctrlKey, button: 0 })); + element.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey, button: 0 })); +} + +function dispatchKeydown(element: HTMLElement, key: string, code: string, keyCode: number): void { + const keyboardEvent = new KeyboardEvent('keydown', { bubbles: true, key, code }); + Object.defineProperty(keyboardEvent, 'keyCode', { get: () => keyCode }); + element.dispatchEvent(keyboardEvent); +} + suite('ObjectTree', function () { suite('TreeNavigator', function () { @@ -231,6 +243,84 @@ suite('ObjectTree', function () { tree.setChildren(null, [{ element: 100 }, { element: 101 }, { element: 102 }, { element: 103 }]); assert.deepStrictEqual(tree.getFocus(), [101]); }); + + test('updateOptions preserves wrapped identity provider in view options', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const identityProvider = { + getId(element: number): { toString(): string } { + return `${element}`; + }, + getGroupId(element: number): number { + return element % 2; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { identityProvider }); + + try { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }, { element: 1 }, { element: 2 }, { element: 3 }]); + + const firstRow = container.querySelector('.monaco-list-row[data-index="0"]') as HTMLElement; + const secondRow = container.querySelector('.monaco-list-row[data-index="1"]') as HTMLElement; + clickElement(firstRow); + assert.deepStrictEqual(tree.getSelection(), [0]); + + tree.updateOptions({ indent: 12 }); + + clickElement(secondRow, true); + + assert.deepStrictEqual(tree.getSelection(), [1]); + } finally { + tree.dispose(); + } + }); + + test('updateOptions preserves wrapped accessibility provider for type navigation re-announce', async function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const accessibilityProvider = { + getAriaLabel(element: number): string { + assert.strictEqual(typeof element, 'number'); + return `aria ${element}`; + }, + getWidgetAriaLabel(): string { + return 'tree'; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { + accessibilityProvider, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: () => 'a' + } + }); + + try { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }]); + tree.setFocus([0]); + tree.domFocus(); + + tree.updateOptions({ indent: 12 }); + + dispatchKeydown(tree.getHTMLElement(), 'a', 'KeyA', 65); + await Promise.resolve(); + }); + } finally { + tree.dispose(); + } + }); }); suite('CompressibleObjectTree', function () { diff --git a/src/vs/base/test/common/event.test.ts b/src/vs/base/test/common/event.test.ts index 4f0369b28b979..cf3c252b90902 100644 --- a/src/vs/base/test/common/event.test.ts +++ b/src/vs/base/test/common/event.test.ts @@ -1006,7 +1006,7 @@ suite('Event utils', () => { const result: number[] = []; const emitter = ds.add(new Emitter()); const event = emitter.event; - const bufferedEvent = Event.buffer(event); + const bufferedEvent = Event.buffer(event, 'test'); emitter.fire(1); emitter.fire(2); @@ -1028,7 +1028,7 @@ suite('Event utils', () => { const result: number[] = []; const emitter = ds.add(new Emitter()); const event = emitter.event; - const bufferedEvent = Event.buffer(event, true); + const bufferedEvent = Event.buffer(event, 'test', true); emitter.fire(1); emitter.fire(2); @@ -1050,7 +1050,7 @@ suite('Event utils', () => { const result: number[] = []; const emitter = ds.add(new Emitter()); const event = emitter.event; - const bufferedEvent = Event.buffer(event, false, [-2, -1, 0]); + const bufferedEvent = Event.buffer(event, 'test', false, [-2, -1, 0]); emitter.fire(1); emitter.fire(2); diff --git a/src/vs/base/test/common/fuzzyScorer.test.ts b/src/vs/base/test/common/fuzzyScorer.test.ts index f120298e22b8f..d7aade68c1dfc 100644 --- a/src/vs/base/test/common/fuzzyScorer.test.ts +++ b/src/vs/base/test/common/fuzzyScorer.test.ts @@ -1239,7 +1239,7 @@ suite('Fuzzy Scorer', () => { let [multiScore, multiMatches] = _doScore2(target, 'HelLo World'); function assertScore() { - assert.ok(multiScore ?? 0 >= ((firstSingleScore ?? 0) + (secondSingleScore ?? 0))); + assert.ok((multiScore ?? 0) >= ((firstSingleScore ?? 0) + (secondSingleScore ?? 0))); for (let i = 0; multiMatches && i < multiMatches.length; i++) { const multiMatch = multiMatches[i]; const firstAndSecondSingleMatch = firstAndSecondSingleMatches[i]; diff --git a/src/vs/base/test/common/jsonRpcProtocol.test.ts b/src/vs/base/test/common/jsonRpcProtocol.test.ts index 4a167d2cc8a2c..9a000e35f48d2 100644 --- a/src/vs/base/test/common/jsonRpcProtocol.test.ts +++ b/src/vs/base/test/common/jsonRpcProtocol.test.ts @@ -39,7 +39,7 @@ suite('JsonRpcProtocol', () => { const requestPromise = protocol.sendRequest({ method: 'echo', params: { value: 'ok' } }); const outgoingRequest = sentMessages[0] as IJsonRpcRequest; - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: outgoingRequest.id, result: 'done' @@ -47,6 +47,7 @@ suite('JsonRpcProtocol', () => { const result = await requestPromise; assert.strictEqual(result, 'done'); + assert.deepStrictEqual(replies, []); }); test('sendRequest rejects on error response', async () => { @@ -107,20 +108,22 @@ suite('JsonRpcProtocol', () => { test('handleRequest responds with method not found without handler', async () => { const { protocol, sentMessages } = createProtocol(); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 7, method: 'unknown' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 7, error: { code: -32601, message: 'Method not found: unknown' } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); test('handleRequest responds with result and passes cancellation token', async () => { @@ -134,7 +137,7 @@ suite('JsonRpcProtocol', () => { } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 9, method: 'compute' @@ -142,27 +145,29 @@ suite('JsonRpcProtocol', () => { assert.ok(receivedToken); assert.strictEqual(wasCanceledDuringHandler, false); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 9, result: 'compute:ok' - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); - test('handleRequest serializes JsonRpcError', async () => { + test('handleRequest serializes JsonRpcError and returns it', async () => { const { protocol, sentMessages } = createProtocol({ handleRequest: () => { throw new JsonRpcError(88, 'bad request', { detail: true }); } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 'a', method: 'boom' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 'a', error: { @@ -170,30 +175,34 @@ suite('JsonRpcProtocol', () => { message: 'bad request', data: { detail: true } } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); - test('handleRequest maps unknown errors to internal error', async () => { + test('handleRequest maps unknown errors to internal error and returns it', async () => { const { protocol, sentMessages } = createProtocol({ handleRequest: () => { throw new Error('unexpected'); } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 'b', method: 'explode' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 'b', error: { code: -32603, message: 'unexpected' } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); test('handleMessage processes batch sequentially', async () => { @@ -225,8 +234,9 @@ suite('JsonRpcProtocol', () => { assert.deepStrictEqual(sequence, ['request:start']); gate.complete(); - await handlingPromise; + const replies = await handlingPromise; assert.deepStrictEqual(sequence, ['request:start', 'request:end', 'notification']); + assert.deepStrictEqual(replies, [{ jsonrpc: '2.0', id: 1, result: true }]); }); }); diff --git a/src/vs/base/test/common/sinonUtils.ts b/src/vs/base/test/common/sinonUtils.ts new file mode 100644 index 0000000000000..ef256b115a088 --- /dev/null +++ b/src/vs/base/test/common/sinonUtils.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; + +export function asSinonMethodStub unknown>(method: T): sinon.SinonStubbedMember { + return method as unknown as sinon.SinonStubbedMember; +} diff --git a/src/vs/base/test/node/crypto.test.ts b/src/vs/base/test/node/crypto.test.ts index ed66c2c4b0d67..37204dc631755 100644 --- a/src/vs/base/test/node/crypto.test.ts +++ b/src/vs/base/test/node/crypto.test.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as assert from 'assert'; import * as fs from 'fs'; import { tmpdir } from 'os'; import { join } from '../../common/path.js'; @@ -33,4 +34,14 @@ flakySuite('Crypto', () => { await checksum(testFile, 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'); }); + + test('checksum mismatch rejects', async () => { + const testFile = join(testDir, 'checksum-mismatch.txt'); + await Promises.writeFile(testFile, 'Hello World'); + + await assert.rejects( + () => checksum(testFile, 'wrong-hash'), + /Hash mismatch/ + ); + }); }); diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 13ff778a58cdf..8ccafe7816e1f 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index dda0dd75b77e4..ce51984cd542d 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 7881f739531cc..b10eb6aeedc58 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -13,7 +13,7 @@ import { toErrorMessage } from '../../base/common/errorMessage.js'; import { Event } from '../../base/common/event.js'; import { parse } from '../../base/common/jsonc.js'; import { getPathLabel } from '../../base/common/labels.js'; -import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../base/common/lifecycle.js'; import { Schemas, VSCODE_AUTHORITY } from '../../base/common/network.js'; import { join, posix } from '../../base/common/path.js'; import { INodeProcess, IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; @@ -41,7 +41,6 @@ import { ipcBrowserViewChannelName } from '../../platform/browserView/common/bro import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js'; import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; import { BrowserViewGroupMainService, IBrowserViewGroupMainService } from '../../platform/browserView/electron-main/browserViewGroupMainService.js'; -import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js'; @@ -122,6 +121,9 @@ import { ipcUtilityProcessWorkerChannelName } from '../../platform/utilityProces import { ILocalPtyService, LocalReconnectConstants, TerminalIpcChannels, TerminalSettingId } from '../../platform/terminal/common/terminal.js'; import { ElectronPtyHostStarter } from '../../platform/terminal/electron-main/electronPtyHostStarter.js'; import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; +import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; +import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; +import { AgentHostEnabledSettingId } from '../../platform/agentHost/common/agentService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; import { Lazy } from '../../base/common/lazy.js'; import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindows.js'; @@ -408,7 +410,11 @@ export class CodeApplication extends Disposable { // Mac only event: open new window when we get activated if (!hasVisibleWindows) { - await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + if ((process as INodeProcess).isEmbeddedApp || (this.environmentMainService.args['sessions'] && this.productService.quality !== 'stable')) { + await this.windowsMainService?.openSessionsWindow({ context: OpenContext.DOCK }); + } else { + await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + } } }); @@ -737,6 +743,7 @@ export class CodeApplication extends Disposable { const openables: IWindowOpenable[] = []; const urls: IProtocolUrl[] = []; + for (const protocolUrl of protocolUrls) { if (!protocolUrl) { continue; // invalid @@ -744,6 +751,12 @@ export class CodeApplication extends Disposable { const windowOpenable = this.getWindowOpenableFromProtocolUrl(protocolUrl.uri); if (windowOpenable) { + // Sessions app: skip all window openables (file/folder/workspace) + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#resolveInitialProtocolUrls() sessions app skipping window openable:', protocolUrl.uri.toString(true)); + continue; + } + if (await this.shouldBlockOpenable(windowOpenable, windowsMainService, dialogMainService)) { this.logService.trace('app#resolveInitialProtocolUrls() protocol url was blocked:', protocolUrl.uri.toString(true)); @@ -889,10 +902,31 @@ export class CodeApplication extends Disposable { private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise { this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options); + // Sessions app: ensure the sessions window is open, then let other handlers process the URL. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#handleProtocolUrl() sessions app handling protocol URL:', uri.toString(true)); + + // Skip window openables (file/folder/workspace) for security + const windowOpenable = this.getWindowOpenableFromProtocolUrl(uri); + if (windowOpenable) { + this.logService.trace('app#handleProtocolUrl() sessions app skipping window openable:', uri.toString(true)); + return true; + } + + // Ensure sessions window is open to receive the URL + const windows = await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + const window = windows.at(0); + window?.focus(); + await window?.ready(); + + // Return false to let subsequent handlers (e.g., URLHandlerChannelClient) forward the URL + return false; + } + // Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263) if (uri.scheme === this.productService.urlProtocol && uri.path === 'workspace') { uri = uri.with({ - authority: 'file', + authority: Schemas.file, path: URI.parse(uri.query).path, query: '' }); @@ -1059,7 +1093,6 @@ export class CodeApplication extends Disposable { services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */)); // Browser View - services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true)); services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */)); @@ -1103,6 +1136,12 @@ export class CodeApplication extends Disposable { ); services.set(ILocalPtyService, ptyHostService); + // Agent Host + if (this.configurationService.getValue(AgentHostEnabledSettingId)) { + const agentHostStarter = new ElectronAgentHostStarter(this.environmentMainService, this.lifecycleMainService, this.logService); + this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); + } + // External terminal if (isWindows) { services.set(IExternalTerminalMainService, new SyncDescriptor(WindowsExternalTerminalService)); @@ -1130,7 +1169,7 @@ export class CodeApplication extends Disposable { const isInternal = isInternalTelemetry(this.productService, this.configurationService); const channel = getDelayedChannel(sharedProcessReady.then(client => client.getChannel('telemetryAppender'))); const appender = new TelemetryAppenderClient(channel); - const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, this.productService.commit, this.productService.version, machineId, sqmId, devDeviceId, isInternal, this.productService.date); + const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, this.productService.commit, this.productService.version, machineId, sqmId, devDeviceId, isInternal, this.productService.date, this.productService.telemetryAppName); const piiPaths = getPiiPathsFromEnvironment(this.environmentMainService); const config: ITelemetryServiceConfig = { appenders: [appender], commonProperties, piiPaths, sendErrorTelemetry: true }; @@ -1284,7 +1323,7 @@ export class CodeApplication extends Disposable { // MCP const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); - const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService))); + const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService), accessor.get(ILoggerMainService))); mainProcessElectronServer.registerChannel(McpGatewayChannelName, mcpGatewayChannel); // Logger @@ -1508,42 +1547,102 @@ export class CodeApplication extends Disposable { const initialGpuFeatureStatus = app.getGPUFeatureStatus() as GPUFeatureStatusWithSkiaGraphite; const skiaGraphiteEnabled: string = initialGpuFeatureStatus['skia_graphite']; if (skiaGraphiteEnabled === 'enabled') { + const gpuInfoUpdate = Event.fromNodeEventEmitter(app, 'gpu-info-update'); + const pendingGpuInfoListener = this._register(new MutableDisposable()); this._register(Event.fromNodeEventEmitter<{ details: Details }>(app, 'child-process-gone', (event, details) => ({ event, details }))(({ details }) => { if (details.type === 'GPU' && details.reason === 'crashed') { - const currentGpuFeatureStatus = app.getGPUFeatureStatus(); - const currentRasterizationStatus: string = currentGpuFeatureStatus['rasterization']; - if (currentRasterizationStatus !== 'enabled') { - // Get last 10 GPU log messages (only the message field) - let gpuLogMessages: string[] = []; - type AppWithGPULogMethod = typeof app & { - getGPULogMessages(): IGPULogMessage[]; - }; - const customApp = app as AppWithGPULogMethod; - if (typeof customApp.getGPULogMessages === 'function') { - gpuLogMessages = customApp.getGPULogMessages().slice(-10).map(log => log.message); + // Wait for gpu-info-update which fires after the GPU process + // restarts and the feature status is refreshed. At the time + // child-process-gone fires, getGPUFeatureStatus() still + // returns the pre-crash status. + pendingGpuInfoListener.value = Event.once(gpuInfoUpdate)(() => { + const currentGpuFeatureStatus = app.getGPUFeatureStatus(); + const currentRasterizationStatus: string = currentGpuFeatureStatus['rasterization']; + if (currentRasterizationStatus !== 'enabled') { + // Get last 10 GPU log messages (only the message field) + let gpuLogMessages: string[] = []; + type AppWithGPULogMethod = typeof app & { + getGPULogMessages(): IGPULogMessage[]; + }; + const customApp = app as AppWithGPULogMethod; + if (typeof customApp.getGPULogMessages === 'function') { + gpuLogMessages = customApp.getGPULogMessages().slice(-10).map(log => log.message); + } + + type GpuCrashEvent = { + readonly gpuFeatureStatus: string; + readonly gpuLogMessages: string; + }; + type GpuCrashClassification = { + gpuFeatureStatus: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Current GPU feature status.' }; + gpuLogMessages: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Last 10 GPU log messages collected after the crash and GPU process restart.' }; + owner: 'deepak1556'; + comment: 'Tracks GPU process crashes that would result in fallback mode.'; + }; + + telemetryService.publicLog2('gpu.crash.fallback', { + gpuFeatureStatus: JSON.stringify(currentGpuFeatureStatus), + gpuLogMessages: JSON.stringify(gpuLogMessages) + }); } - - type GpuCrashEvent = { - readonly gpuFeatureStatus: string; - readonly gpuLogMessages: string; - }; - type GpuCrashClassification = { - gpuFeatureStatus: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Current GPU feature status.' }; - gpuLogMessages: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Last 10 GPU log messages before crash.' }; - owner: 'deepak1556'; - comment: 'Tracks GPU process crashes that would result in fallback mode.'; - }; - - telemetryService.publicLog2('gpu.crash.fallback', { - gpuFeatureStatus: JSON.stringify(currentGpuFeatureStatus), - gpuLogMessages: JSON.stringify(gpuLogMessages) - }); - } + }); } })); } }); } + + { + interface NetworkProcessLaunchedDetails { + readonly pid: number; + } + interface NetworkProcessGoneDetails { + readonly pid: number; + readonly exitCode: number; + readonly crashed: boolean; + readonly crashedPreIPC: boolean; + } + + type AppWithNetworkProcessEvents = typeof app & { + on(event: 'network-process-launched', listener: (event: Electron.Event, details: NetworkProcessLaunchedDetails) => void): typeof app; + on(event: 'network-process-gone', listener: (event: Electron.Event, details: NetworkProcessGoneDetails) => void): typeof app; + }; + + const customApp = app as AppWithNetworkProcessEvents; + + instantiationService.invokeFunction(accessor => { + const telemetryService = accessor.get(ITelemetryService); + + type NetworkProcessLaunchedClassification = { + owner: 'deepak1556'; + comment: 'Tracks network process launch events.'; + }; + + type NetworkProcessGoneClassification = { + exitCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The exit code of the network process.' }; + crashed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the network process crashed.' }; + crashedPreIPC: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the network process crashed before IPC was established.' }; + owner: 'deepak1556'; + comment: 'Tracks network process gone events for reliability insights.'; + }; + + this._register(Event.fromNodeEventEmitter(customApp, 'network-process-launched', (_event, details) => details)(details => { + this.logService.info(`[network process] launched with pid ${details.pid}`); + + telemetryService.publicLog2<{}, NetworkProcessLaunchedClassification>('networkProcess.launched', {}); + })); + + this._register(Event.fromNodeEventEmitter(customApp, 'network-process-gone', (_event, details) => details)(details => { + this.logService.info(`[network process] gone - pid: ${details.pid}, exitCode: ${details.exitCode}, crashed: ${details.crashed}, crashedPreIPC: ${details.crashedPreIPC}`); + + telemetryService.publicLog2<{ exitCode: number; crashed: boolean; crashedPreIPC: boolean }, NetworkProcessGoneClassification>('networkProcess.gone', { + exitCode: details.exitCode, + crashed: details.crashed, + crashedPreIPC: details.crashedPreIPC + }); + })); + }); + } } private async installMutex(): Promise { diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index e112b958d730e..23253ba21ea59 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -134,9 +134,7 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; -import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js'; -import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js'; -import { IBrowserViewGroupRemoteService, BrowserViewGroupRemoteService } from '../../../platform/browserView/node/browserViewGroupRemoteService.js'; +import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -327,7 +325,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { telemetryService = new TelemetryService({ appenders, - commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, this.configuration.machineId, this.configuration.sqmId, this.configuration.devDeviceId, internalTelemetry, productService.date), + commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, this.configuration.machineId, this.configuration.sqmId, this.configuration.devDeviceId, internalTelemetry, productService.date, productService.telemetryAppName), sendErrorTelemetry: true, piiPaths: getPiiPathsFromEnvironment(environmentService), meteredConnectionService, @@ -404,10 +402,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); - // Playwright - services.set(IBrowserViewGroupRemoteService, new SyncDescriptor(BrowserViewGroupRemoteService)); - services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService)); - return new InstantiationService(services); } @@ -476,7 +470,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); // Playwright - const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store); + const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService))); this.server.registerChannel('playwright', playwrightChannel); } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 8e29f4924766b..5f50659ff46c2 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, spawn, SpawnOptions, StdioOptions } from 'child_process'; -import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync } from 'fs'; +import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync, promises } from 'fs'; import { homedir, tmpdir } from 'os'; import type { ProfilingSession, Target } from 'v8-inspect-profiler'; import { Event } from '../../base/common/event.js'; @@ -499,8 +499,26 @@ export async function main(argv: string[]): Promise { // This way, Mac does not automatically try to foreground the new instance, which causes // focusing issues when the new instance only sends data to a previous instance and then closes. const spawnArgs = ['-n', '-g']; - // -a opens the given application. - spawnArgs.push('-a', process.execPath); // -a: opens a specific application + + // Figure out the app to launch: with --sessions we try to launch the embedded app + let appToLaunch = process.execPath; + if (args.sessions) { + // process.execPath is e.g. /Applications/Code.app/Contents/MacOS/Electron + // Embedded app is at /Applications/Code.app/Contents/Applications/.app + const contentsPath = dirname(dirname(process.execPath)); + const applicationsPath = join(contentsPath, 'Applications'); + try { + const files = await promises.readdir(applicationsPath); + const embeddedApp = files.find(file => file.endsWith('.app')); + if (embeddedApp) { + appToLaunch = join(applicationsPath, embeddedApp); + argv = argv.filter(arg => arg !== '--sessions'); + } + } catch (error) { + /* may not exist on disk */ + } + } + spawnArgs.push('-a', appToLaunch); // -a opens the given application. if (args.verbose || args.status) { spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running) diff --git a/src/vs/code/node/cliProcessMain.ts b/src/vs/code/node/cliProcessMain.ts index 41d94cc492faa..d0eaeb1cf16df 100644 --- a/src/vs/code/node/cliProcessMain.ts +++ b/src/vs/code/node/cliProcessMain.ts @@ -257,7 +257,7 @@ class CliMain extends Disposable { const config: ITelemetryServiceConfig = { appenders, sendErrorTelemetry: false, - commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, isInternal, productService.date), + commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, isInternal, productService.date, productService.telemetryAppName), piiPaths: getPiiPathsFromEnvironment(environmentService) }; diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts index 88d97714e7ce2..528a8e0f590fc 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts @@ -58,6 +58,7 @@ export class NativeEditContext extends AbstractEditContext { private readonly _editContext: EditContext; private readonly _screenReaderSupport: ScreenReaderSupport; private _previousEditContextSelection: OffsetRange = new OffsetRange(0, 0); + private _previousEditContextText: string = ''; private _editContextPrimarySelection: Selection = new Selection(1, 1, 1, 1); // Overflow guard container @@ -247,6 +248,19 @@ export class NativeEditContext extends AbstractEditContext { } })); this._register(NativeEditContextRegistry.register(ownerID, this)); + this._register(context.viewModel.model.onDidChangeContent((e) => { + let doChange = false; + for (const change of e.changes) { + if (change.range.startLineNumber <= this._editContextPrimarySelection.endLineNumber + && change.range.endLineNumber >= this._editContextPrimarySelection.startLineNumber) { + doChange = true; + break; + } + } + if (doChange) { + this._updateEditContext(); + } + })); } // --- Public methods --- @@ -310,27 +324,17 @@ export class NativeEditContext extends AbstractEditContext { } public override onLinesChanged(e: ViewLinesChangedEvent): boolean { - this._updateEditContextOnLineChange(e.fromLineNumber, e.fromLineNumber + e.count - 1); return true; } public override onLinesDeleted(e: ViewLinesDeletedEvent): boolean { - this._updateEditContextOnLineChange(e.fromLineNumber, e.toLineNumber); return true; } public override onLinesInserted(e: ViewLinesInsertedEvent): boolean { - this._updateEditContextOnLineChange(e.fromLineNumber, e.toLineNumber); return true; } - private _updateEditContextOnLineChange(fromLineNumber: number, toLineNumber: number): void { - if (this._editContextPrimarySelection.endLineNumber < fromLineNumber || this._editContextPrimarySelection.startLineNumber > toLineNumber) { - return; - } - this._updateEditContext(); - } - public override onScrollChanged(e: ViewScrollChangedEvent): boolean { this._scrollLeft = e.scrollLeft; this._scrollTop = e.scrollTop; @@ -412,8 +416,15 @@ export class NativeEditContext extends AbstractEditContext { if (!editContextState) { return; } - this._editContext.updateText(0, Number.MAX_SAFE_INTEGER, editContextState.text ?? ' '); - this._editContext.updateSelection(editContextState.selectionStartOffset, editContextState.selectionEndOffset); + const newText = editContextState.text ?? ' '; + if (newText !== this._previousEditContextText) { + this._editContext.updateText(0, this._previousEditContextText.length, newText); + this._previousEditContextText = newText; + } + if (editContextState.selectionStartOffset !== this._previousEditContextSelection.start || + editContextState.selectionEndOffset !== this._previousEditContextSelection.endExclusive) { + this._editContext.updateSelection(editContextState.selectionStartOffset, editContextState.selectionEndOffset); + } this._editContextPrimarySelection = editContextState.editContextPrimarySelection; this._previousEditContextSelection = new OffsetRange(editContextState.selectionStartOffset, editContextState.selectionEndOffset); } diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index 881275f34af4a..1c88653206fac 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -9,11 +9,11 @@ import * as strings from '../../../base/common/strings.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { applyFontInfo } from '../config/domFontInfo.js'; import { WrappingIndent } from '../../common/config/editorOptions.js'; -import { FontInfo } from '../../common/config/fontInfo.js'; import { StringBuilder } from '../../common/core/stringBuilder.js'; import { InjectedTextOptions } from '../../common/model.js'; -import { ILineBreaksComputer, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../common/modelLineProjectionData.js'; +import { ILineBreaksComputer, ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../common/modelLineProjectionData.js'; import { LineInjectedText } from '../../common/textModelEvents.js'; +import { FontInfo } from '../../common/config/fontInfo.js'; const ttPolicy = createTrustedTypesPolicy('domLineBreaksComputer', { createHTML: value => value }); @@ -26,26 +26,25 @@ export class DOMLineBreaksComputerFactory implements ILineBreaksComputerFactory constructor(private targetWindow: WeakRef) { } - public createLineBreaksComputer(fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer { - const requests: string[] = []; - const injectedTexts: (LineInjectedText[] | null)[] = []; + public createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer { + const lineNumbers: number[] = []; return { - addRequest: (lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null) => { - requests.push(lineText); - injectedTexts.push(injectedText); + addRequest: (lineNumber: number, previousLineBreakData: ModelLineProjectionData | null) => { + lineNumbers.push(lineNumber); }, finalize: () => { - return createLineBreaks(assertReturnsDefined(this.targetWindow.deref()), requests, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, injectedTexts); + return createLineBreaks(assertReturnsDefined(this.targetWindow.deref()), context, lineNumbers, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak); } }; } } -function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', injectedTextsPerLine: (LineInjectedText[] | null)[]): (ModelLineProjectionData | null)[] { - function createEmptyLineBreakWithPossiblyInjectedText(requestIdx: number): ModelLineProjectionData | null { - const injectedTexts = injectedTextsPerLine[requestIdx]; +function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerContext, lineNumbers: number[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): (ModelLineProjectionData | null)[] { + function createEmptyLineBreakWithPossiblyInjectedText(lineNumber: number): ModelLineProjectionData | null { + const injectedTexts = context.getLineInjectedText(lineNumber); if (injectedTexts) { - const lineText = LineInjectedText.applyInjectedText(requests[requestIdx], injectedTexts); + const lineContent = context.getLineContent(lineNumber); + const lineText = LineInjectedText.applyInjectedText(lineContent, injectedTexts); const injectionOptions = injectedTexts.map(t => t.options); const injectionOffsets = injectedTexts.map(text => text.column - 1); @@ -60,8 +59,8 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo if (firstLineBreakColumn === -1) { const result: (ModelLineProjectionData | null)[] = []; - for (let i = 0, len = requests.length; i < len; i++) { - result[i] = createEmptyLineBreakWithPossiblyInjectedText(i); + for (let i = 0, len = lineNumbers.length; i < len; i++) { + result[i] = createEmptyLineBreakWithPossiblyInjectedText(lineNumbers[i]); } return result; } @@ -80,8 +79,9 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo const renderLineContents: string[] = []; const allCharOffsets: number[][] = []; const allVisibleColumns: number[][] = []; - for (let i = 0; i < requests.length; i++) { - const lineContent = LineInjectedText.applyInjectedText(requests[i], injectedTextsPerLine[i]); + for (let i = 0; i < lineNumbers.length; i++) { + const lineNumber = lineNumbers[i]; + const lineContent = LineInjectedText.applyInjectedText(context.getLineContent(lineNumber), context.getLineInjectedText(lineNumber)); let firstNonWhitespaceIndex = 0; let wrappedTextIndentLength = 0; @@ -146,11 +146,12 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo const lineDomNodes = Array.prototype.slice.call(containerDomNode.children, 0); const result: (ModelLineProjectionData | null)[] = []; - for (let i = 0; i < requests.length; i++) { + for (let i = 0; i < lineNumbers.length; i++) { + const lineNumber = lineNumbers[i]; const lineDomNode = lineDomNodes[i]; const breakOffsets: number[] | null = readLineBreaks(range, lineDomNode, renderLineContents[i], allCharOffsets[i]); if (breakOffsets === null) { - result[i] = createEmptyLineBreakWithPossiblyInjectedText(i); + result[i] = createEmptyLineBreakWithPossiblyInjectedText(lineNumber); continue; } @@ -172,7 +173,7 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo let injectionOptions: InjectedTextOptions[] | null; let injectionOffsets: number[] | null; - const curInjectedTexts = injectedTextsPerLine[i]; + const curInjectedTexts = context.getLineInjectedText(lineNumber); if (curInjectedTexts) { injectionOptions = curInjectedTexts.map(t => t.options); injectionOffsets = curInjectedTexts.map(text => text.column - 1); @@ -306,7 +307,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: try { discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets); } catch (err) { - console.log(err); + console.error(err); return null; } diff --git a/src/vs/editor/browser/viewParts/minimap/minimap.css b/src/vs/editor/browser/viewParts/minimap/minimap.css index 35bb6e3b7178b..e6ced7d3dc851 100644 --- a/src/vs/editor/browser/viewParts/minimap/minimap.css +++ b/src/vs/editor/browser/viewParts/minimap/minimap.css @@ -25,7 +25,7 @@ background: var(--vscode-minimapSlider-activeBackground); } .monaco-editor .minimap-shadow-visible { - box-shadow: var(--vscode-scrollbar-shadow) -6px 0 6px -6px inset; + box-shadow: var(--vscode-shadow-md); } .monaco-editor .minimap-shadow-hidden { position: absolute; @@ -61,3 +61,7 @@ .monaco-editor .minimap { z-index: 5; } + +.monaco-editor .minimap canvas { + opacity: 0.9; +} diff --git a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts index 4aaa9200561a9..3bf79d8b67925 100644 --- a/src/vs/editor/browser/viewParts/viewLines/viewLine.ts +++ b/src/vs/editor/browser/viewParts/viewLines/viewLine.ts @@ -116,7 +116,7 @@ export class ViewLine implements IVisibleLine { const lineData = viewportData.getViewLineRenderingData(lineNumber); const options = this._options; const actualInlineDecorations = LineDecoration.filter(lineData.inlineDecorations, lineNumber, lineData.minColumn, lineData.maxColumn); - const renderWhitespace = (lineData.hasVariableFonts || options.experimentalWhitespaceRendering === 'off') ? options.renderWhitespace : 'none'; + const renderWhitespace = options.experimentalWhitespaceRendering === 'off' ? options.renderWhitespace : 'none'; const allowFastRendering = !lineData.hasVariableFonts; // Only send selection information when needed for rendering whitespace diff --git a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts index 546d268130c31..42ee0e30dade4 100644 --- a/src/vs/editor/browser/viewParts/whitespace/whitespace.ts +++ b/src/vs/editor/browser/viewParts/whitespace/whitespace.ts @@ -122,9 +122,6 @@ export class WhitespaceOverlay extends DynamicViewOverlay { } private _applyRenderWhitespace(ctx: RenderingContext, lineNumber: number, selections: OffsetRange[] | null, lineData: ViewLineRenderingData): string { - if (lineData.hasVariableFonts) { - return ''; - } if (this._options.renderWhitespace === 'selection' && !selections) { return ''; } diff --git a/src/vs/editor/browser/widget/codeEditor/editor.css b/src/vs/editor/browser/widget/codeEditor/editor.css index d33122122dedf..638055a055d57 100644 --- a/src/vs/editor/browser/widget/codeEditor/editor.css +++ b/src/vs/editor/browser/widget/codeEditor/editor.css @@ -21,6 +21,7 @@ position: relative; overflow: visible; -webkit-text-size-adjust: 100%; + text-spacing-trim: space-all; color: var(--vscode-editor-foreground); background-color: var(--vscode-editor-background); overflow-wrap: initial; diff --git a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts index 62bf7ece01a52..56d3850c945ef 100644 --- a/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts +++ b/src/vs/editor/browser/widget/diffEditor/components/diffEditorViewZones/diffEditorViewZones.ts @@ -31,6 +31,7 @@ import { IContextMenuService } from '../../../../../../platform/contextview/brow import { DiffEditorOptions } from '../../diffEditorOptions.js'; import { Range } from '../../../../../common/core/range.js'; import { InlineDecoration, InlineDecorationType } from '../../../../../common/viewModel/inlineDecorations.js'; +import { ILineBreaksComputerContext } from '../../../../../common/modelLineProjectionData.js'; /** * Ensures both editors have the same height by aligning unchanged lines. @@ -163,8 +164,15 @@ export class DiffEditorViewZones extends Disposable { } const renderSideBySide = this._options.renderSideBySide.read(reader); - - const deletedCodeLineBreaksComputer = !renderSideBySide ? this._editors.modified._getViewModel()?.createLineBreaksComputer() : undefined; + const context: ILineBreaksComputerContext = { + getLineContent: (lineNumber: number): string => { + return this._editors.original.getModel()!.getLineContent(lineNumber); + }, + getLineInjectedText: (lineNumber: number) => { + return null; + } + }; + const deletedCodeLineBreaksComputer = !renderSideBySide ? this._editors.modified._getViewModel()?.createLineBreaksComputer(context) : undefined; if (deletedCodeLineBreaksComputer) { const originalModel = this._editors.original.getModel()!; for (const a of alignmentsVal) { @@ -176,7 +184,7 @@ export class DiffEditorViewZones extends Disposable { if (i > originalModel.getLineCount()) { return { orig: origViewZones, mod: modViewZones }; } - deletedCodeLineBreaksComputer?.addRequest(originalModel.getLineContent(i), null, null); + deletedCodeLineBreaksComputer?.addRequest(i, null); } } } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 34cdd09c350c4..313793845dd64 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -1744,6 +1744,10 @@ export interface IEditorFindOptions { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; /** * @internal * Controls how the find widget search history should be stored @@ -1772,6 +1776,7 @@ class EditorFind extends BaseEditorOption(input.history, this.defaultValue.history, ['never', 'workspace']), replaceHistory: stringSet<'never' | 'workspace'>(input.replaceHistory, this.defaultValue.replaceHistory, ['never', 'workspace']), }; @@ -2322,6 +2333,11 @@ export interface IEditorHoverOptions { * Defaults to false. */ above?: boolean; + /** + * Should long line warning hovers be shown (tokenization skipped, rendering paused)? + * Defaults to true. + */ + showLongLineWarning?: boolean; } /** @@ -2338,6 +2354,7 @@ class EditorHover extends BaseEditorOption { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.endLineNumber + : cursor.modelState.position.lineNumber; + + const targetLine = CursorMoveCommands._targetFoldedDown(startLine, count, hiddenAreas, lineCount); + const delta = targetLine - startLine; + if (delta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveDown(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); + }); + } + + private static _moveUpByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { + const model = viewModel.model; + const hiddenAreas = viewModel.getHiddenAreas(); + + return cursors.map(cursor => { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.startLineNumber + : cursor.modelState.position.lineNumber; + + const targetLine = CursorMoveCommands._targetFoldedUp(startLine, count, hiddenAreas); + const delta = startLine - targetLine; + if (delta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveUp(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); + }); + } + + // Compute the target line after moving `count` steps downward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedDown(startLine: number, count: number, hiddenAreas: Range[], lineCount: number): number { + let line = startLine; + let i = 0; + + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < line + 1) { + i++; + } + + for (let step = 0; step < count; step++) { + if (line >= lineCount) { + return lineCount; + } + + let candidate = line + 1; + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < candidate) { + i++; + } + + if (i < hiddenAreas.length && hiddenAreas[i].startLineNumber <= candidate) { + candidate = hiddenAreas[i].endLineNumber + 1; + } + + if (candidate > lineCount) { + // The next visible line does not exist (e.g. a fold reaches EOF). + return line; + } + + line = candidate; + } + + return line; + } + + // Compute the target line after moving `count` steps upward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedUp(startLine: number, count: number, hiddenAreas: Range[]): number { + let line = startLine; + let i = hiddenAreas.length - 1; + + while (i >= 0 && hiddenAreas[i].startLineNumber > line - 1) { + i--; + } + + for (let step = 0; step < count; step++) { + if (line <= 1) { + return 1; + } + + let candidate = line - 1; + while (i >= 0 && hiddenAreas[i].startLineNumber > candidate) { + i--; + } + + if (i >= 0 && hiddenAreas[i].endLineNumber >= candidate) { + candidate = hiddenAreas[i].startLineNumber - 1; + } + + if (candidate < 1) { + // The previous visible line does not exist (e.g. a fold reaches BOF). + return line; + } + + line = candidate; + } + + return line; + } + private static _moveToViewPosition(viewModel: IViewModel, cursor: CursorState, inSelectionMode: boolean, toViewLineNumber: number, toViewColumn: number): PartialCursorState { return CursorState.fromViewState(cursor.viewState.move(inSelectionMode, toViewLineNumber, toViewColumn, 0)); } @@ -626,8 +739,10 @@ export namespace CursorMove { \`\`\` * 'by': Unit to move. Default is computed based on 'to' value. \`\`\` - 'line', 'wrappedLine', 'character', 'halfLine' + 'line', 'wrappedLine', 'character', 'halfLine', 'foldedLine' \`\`\` + Use 'foldedLine' with 'up'/'down' to move by logical lines while treating each + folded region as a single step. * 'value': Number of units to move. Default is '1'. * 'select': If 'true' makes the selection. Default is 'false'. * 'noHistory': If 'true' does not add the movement to navigation history. Default is 'false'. @@ -643,7 +758,7 @@ export namespace CursorMove { }, 'by': { 'type': 'string', - 'enum': ['line', 'wrappedLine', 'character', 'halfLine'] + 'enum': ['line', 'wrappedLine', 'character', 'halfLine', 'foldedLine'] }, 'value': { 'type': 'number', @@ -695,7 +810,8 @@ export namespace CursorMove { Line: 'line', WrappedLine: 'wrappedLine', Character: 'character', - HalfLine: 'halfLine' + HalfLine: 'halfLine', + FoldedLine: 'foldedLine' }; /** @@ -781,6 +897,9 @@ export namespace CursorMove { case RawUnit.HalfLine: unit = Unit.HalfLine; break; + case RawUnit.FoldedLine: + unit = Unit.FoldedLine; + break; } return { @@ -855,6 +974,7 @@ export namespace CursorMove { WrappedLine, Character, HalfLine, + FoldedLine, } } diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index f141c78d98eaa..33e90ab7f7572 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -826,7 +826,7 @@ export class SelectedSuggestionInfo { ) { } - public equals(other: SelectedSuggestionInfo) { + public equals(other: SelectedSuggestionInfo): boolean { return Range.lift(this.range).equalsRange(other.range) && this.text === other.text && this.completionKind === other.completionKind diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 5195e0b635374..2fc027b34a231 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -19,7 +19,7 @@ import { IWordAtPosition } from './core/wordHelper.js'; import { FormattingOptions } from './languages.js'; import { ILanguageSelection } from './languages/language.js'; import { IBracketPairsTextModelPart } from './textModelBracketPairs.js'; -import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, ModelFontChangedEvent, ModelLineHeightChangedEvent } from './textModelEvents.js'; +import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent, LineInjectedText, ModelFontChangedEvent, ModelLineHeightChangedEvent } from './textModelEvents.js'; import { IModelContentChange } from './model/mirrorTextModel.js'; import { IGuidesTextModelPart } from './textModelGuides.js'; import { ITokenizationTextModelPart } from './tokenizationTextModelPart.js'; @@ -856,6 +856,12 @@ export interface ITextModel { */ getLineContent(lineNumber: number): string; + /** + * Get the line injected text for a certain line. + * @internal + */ + getLineInjectedText(lineNumber: number, ownerId?: number): LineInjectedText[]; + /** * Get the text length for a certain line. */ diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 4d3ae947ad6e5..6d7e5a6be5503 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ArrayQueue, pushMany } from '../../../base/common/arrays.js'; +import { pushMany } from '../../../base/common/arrays.js'; import { VSBuffer, VSBufferReadableStream } from '../../../base/common/buffer.js'; import { CharCode } from '../../../base/common/charCode.js'; import { SetWithKey } from '../../../base/common/collections.js'; @@ -1539,65 +1539,36 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati const changeLineCountDelta = (insertingLinesCnt - deletingLinesCnt); const currentEditStartLineNumber = newLineCount - lineCount - changeLineCountDelta + startLineNumber; - const firstEditLineNumber = currentEditStartLineNumber; - const lastInsertedLineNumber = currentEditStartLineNumber + insertingLinesCnt; - - const decorationsWithInjectedTextInEditedRange = this._decorationsTree.getInjectedTextInInterval( - this, - this.getOffsetAt(new Position(firstEditLineNumber, 1)), - this.getOffsetAt(new Position(lastInsertedLineNumber, this.getLineMaxColumn(lastInsertedLineNumber))), - 0 - ); - - - const injectedTextInEditedRange = LineInjectedText.fromDecorations(decorationsWithInjectedTextInEditedRange); - const injectedTextInEditedRangeQueue = new ArrayQueue(injectedTextInEditedRange); for (let j = editingLinesCnt; j >= 0; j--) { const editLineNumber = startLineNumber + j; const currentEditLineNumber = currentEditStartLineNumber + j; - injectedTextInEditedRangeQueue.takeFromEndWhile(r => r.lineNumber > currentEditLineNumber); - const decorationsInCurrentLine = injectedTextInEditedRangeQueue.takeFromEndWhile(r => r.lineNumber === currentEditLineNumber); - rawContentChanges.push( new ModelRawLineChanged( editLineNumber, - currentEditLineNumber, - this.getLineContent(currentEditLineNumber), - decorationsInCurrentLine + currentEditLineNumber )); } if (editingLinesCnt < deletingLinesCnt) { // Must delete some lines const spliceStartLineNumber = startLineNumber + editingLinesCnt; - rawContentChanges.push(new ModelRawLinesDeleted(spliceStartLineNumber + 1, endLineNumber)); + const cnt = insertingLinesCnt - deletingLinesCnt; + const lastUntouchedLinePostEdit = newLineCount - lineCount - cnt + spliceStartLineNumber; + rawContentChanges.push(new ModelRawLinesDeleted(spliceStartLineNumber + 1, endLineNumber, lastUntouchedLinePostEdit)); } if (editingLinesCnt < insertingLinesCnt) { - const injectedTextInEditedRangeQueue = new ArrayQueue(injectedTextInEditedRange); // Must insert some lines const spliceLineNumber = startLineNumber + editingLinesCnt; const cnt = insertingLinesCnt - editingLinesCnt; const fromLineNumber = newLineCount - lineCount - cnt + spliceLineNumber + 1; - const injectedTexts: (LineInjectedText[] | null)[] = []; - const newLines: string[] = []; - for (let i = 0; i < cnt; i++) { - const lineNumber = fromLineNumber + i; - newLines[i] = this.getLineContent(lineNumber); - - injectedTextInEditedRangeQueue.takeWhile(r => r.lineNumber < lineNumber); - injectedTexts[i] = injectedTextInEditedRangeQueue.takeWhile(r => r.lineNumber === lineNumber); - } - rawContentChanges.push( new ModelRawLinesInserted( spliceLineNumber + 1, fromLineNumber, - cnt, - newLines, - injectedTexts + cnt ) ); } @@ -1655,7 +1626,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (affectedInjectedTextLines && affectedInjectedTextLines.size > 0) { const affectedLines = Array.from(affectedInjectedTextLines); - const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, lineNumber, this.getLineContent(lineNumber), this._getInjectedTextInLine(lineNumber))); + const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, lineNumber)); this._onDidChangeContentOrInjectedText(new ModelInjectedTextChangedEvent(lineChangeEvents)); } this._fireOnDidChangeLineHeight(affectedLineHeights); @@ -1881,11 +1852,11 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return decs; } - private _getInjectedTextInLine(lineNumber: number): LineInjectedText[] { + public getLineInjectedText(lineNumber: number, ownerId: number = 0): LineInjectedText[] { const startOffset = this._buffer.getOffsetAt(lineNumber, 1); const endOffset = startOffset + this._buffer.getLineLength(lineNumber); - const result = this._decorationsTree.getInjectedTextInInterval(this, startOffset, endOffset, 0); + const result = this._decorationsTree.getInjectedTextInInterval(this, startOffset, endOffset, ownerId); return LineInjectedText.fromDecorations(result).filter(t => t.lineNumber === lineNumber); } diff --git a/src/vs/editor/common/modelLineProjectionData.ts b/src/vs/editor/common/modelLineProjectionData.ts index aac6ae4642d68..948214355ef3f 100644 --- a/src/vs/editor/common/modelLineProjectionData.ts +++ b/src/vs/editor/common/modelLineProjectionData.ts @@ -328,14 +328,19 @@ export class OutputPosition { } } +export interface ILineBreaksComputerContext { + getLineContent(lineNumber: number): string; + getLineInjectedText(lineNumber: number): LineInjectedText[] | null; +} + export interface ILineBreaksComputerFactory { - createLineBreaksComputer(fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer; + createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer; } export interface ILineBreaksComputer { /** * Pass in `previousLineBreakData` if the only difference is in breaking columns!!! */ - addRequest(lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null): void; + addRequest(lineNumber: number, previousLineBreakData: ModelLineProjectionData | null): void; finalize(): (ModelLineProjectionData | null)[]; } diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 4fa24afd2c1b0..1f504db5852e6 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -315,20 +315,10 @@ export class ModelRawLineChanged { * The new line number the old one is mapped to (after the change was applied). */ public readonly lineNumberPostEdit: number; - /** - * The new value of the line. - */ - public readonly detail: string; - /** - * The injected text on the line. - */ - public readonly injectedText: LineInjectedText[] | null; - constructor(lineNumber: number, lineNumberPostEdit: number, detail: string, injectedText: LineInjectedText[] | null) { + constructor(lineNumber: number, lineNumberPostEdit: number) { this.lineNumber = lineNumber; this.lineNumberPostEdit = lineNumberPostEdit; - this.detail = detail; - this.injectedText = injectedText; } } @@ -397,10 +387,15 @@ export class ModelRawLinesDeleted { * At what line the deletion stopped (inclusive). */ public readonly toLineNumber: number; + /** + * The last unmodified line in the updated buffer after the deletion is made. + */ + public readonly lastUntouchedLinePostEdit: number; - constructor(fromLineNumber: number, toLineNumber: number) { + constructor(fromLineNumber: number, toLineNumber: number, lastUntouchedLinePostEdit: number) { this.fromLineNumber = fromLineNumber; this.toLineNumber = toLineNumber; + this.lastUntouchedLinePostEdit = lastUntouchedLinePostEdit; } } @@ -434,21 +429,11 @@ export class ModelRawLinesInserted { public get toLineNumberPostEdit(): number { return this.fromLineNumberPostEdit + this.count - 1; } - /** - * The text that was inserted - */ - public readonly detail: string[]; - /** - * The injected texts for every inserted line. - */ - public readonly injectedTexts: (LineInjectedText[] | null)[]; - constructor(fromLineNumber: number, fromLineNumberPostEdit: number, count: number, detail: string[], injectedTexts: (LineInjectedText[] | null)[]) { - this.injectedTexts = injectedTexts; + constructor(fromLineNumber: number, fromLineNumberPostEdit: number, count: number) { this.fromLineNumber = fromLineNumber; this.fromLineNumberPostEdit = fromLineNumberPostEdit; this.count = count; - this.detail = detail; } } diff --git a/src/vs/editor/common/viewLayout/lineHeights.ts b/src/vs/editor/common/viewLayout/lineHeights.ts index 215d210f9fda1..3c5ab572d66a6 100644 --- a/src/vs/editor/common/viewLayout/lineHeights.ts +++ b/src/vs/editor/common/viewLayout/lineHeights.ts @@ -21,7 +21,7 @@ type PendingChange = | { readonly kind: PendingChangeKind.InsertOrChange; readonly decorationId: string; readonly startLineNumber: number; readonly endLineNumber: number; readonly lineHeight: number } | { readonly kind: PendingChangeKind.Remove; readonly decorationId: string } | { readonly kind: PendingChangeKind.LinesDeleted; readonly fromLineNumber: number; readonly toLineNumber: number } - | { readonly kind: PendingChangeKind.LinesInserted; readonly fromLineNumber: number; readonly toLineNumber: number; readonly lineHeightsAdded: CustomLineHeightData[] }; + | { readonly kind: PendingChangeKind.LinesInserted; readonly fromLineNumber: number; readonly toLineNumber: number }; export class CustomLine { @@ -132,8 +132,8 @@ export class LineHeightsManager { this._hasPending = true; } - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { - this._pendingChanges.push({ kind: PendingChangeKind.LinesInserted, fromLineNumber, toLineNumber, lineHeightsAdded }); + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + this._pendingChanges.push({ kind: PendingChangeKind.LinesInserted, fromLineNumber, toLineNumber }); this._hasPending = true; } @@ -146,28 +146,29 @@ export class LineHeightsManager { this._hasPending = false; const stagedInserts: CustomLine[] = []; + const stagedIdMap = new ArrayMap(); for (const change of changes) { switch (change.kind) { case PendingChangeKind.Remove: - this._doRemoveCustomLineHeight(change.decorationId, stagedInserts); + this._doRemoveCustomLineHeight(change.decorationId, stagedIdMap); break; case PendingChangeKind.InsertOrChange: - this._doInsertOrChangeCustomLineHeight(change.decorationId, change.startLineNumber, change.endLineNumber, change.lineHeight, stagedInserts); + this._doInsertOrChangeCustomLineHeight(change.decorationId, change.startLineNumber, change.endLineNumber, change.lineHeight, stagedInserts, stagedIdMap); break; case PendingChangeKind.LinesDeleted: - this._flushStagedDecorationChanges(stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); this._doLinesDeleted(change.fromLineNumber, change.toLineNumber); break; case PendingChangeKind.LinesInserted: - this._flushStagedDecorationChanges(stagedInserts); - this._doLinesInserted(change.fromLineNumber, change.toLineNumber, change.lineHeightsAdded, stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); + this._doLinesInserted(change.fromLineNumber, change.toLineNumber, stagedInserts, stagedIdMap); break; } } - this._flushStagedDecorationChanges(stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); } - private _doRemoveCustomLineHeight(decorationID: string, stagedInserts: CustomLine[]): void { + private _doRemoveCustomLineHeight(decorationID: string, stagedIdMap: ArrayMap): void { const customLines = this._decorationIDToCustomLine.get(decorationID); if (customLines) { this._decorationIDToCustomLine.delete(decorationID); @@ -176,32 +177,42 @@ export class LineHeightsManager { this._invalidIndex = Math.min(this._invalidIndex, customLine.index); } } - for (let i = stagedInserts.length - 1; i >= 0; i--) { - if (stagedInserts[i].decorationId === decorationID) { - stagedInserts.splice(i, 1); + const stagedLines = stagedIdMap.get(decorationID); + if (stagedLines) { + stagedIdMap.delete(decorationID); + for (const line of stagedLines) { + line.deleted = true; } } } - private _doInsertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number, stagedInserts: CustomLine[]): void { - this._doRemoveCustomLineHeight(decorationId, stagedInserts); + private _doInsertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number, stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { + this._doRemoveCustomLineHeight(decorationId, stagedIdMap); for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { const customLine = new CustomLine(decorationId, -1, lineNumber, lineHeight, 0); stagedInserts.push(customLine); + stagedIdMap.add(decorationId, customLine); } } - private _flushStagedDecorationChanges(stagedInserts: CustomLine[]): void { + private _flushStagedDecorationChanges(stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { if (stagedInserts.length === 0 && this._invalidIndex === Infinity) { return; } for (const pendingChange of stagedInserts) { + if (pendingChange.deleted) { + continue; + } const candidateInsertionIndex = this._binarySearchOverOrderedCustomLinesArray(pendingChange.lineNumber); const insertionIndex = candidateInsertionIndex >= 0 ? candidateInsertionIndex : -(candidateInsertionIndex + 1); this._orderedCustomLines.splice(insertionIndex, 0, pendingChange); this._invalidIndex = Math.min(this._invalidIndex, insertionIndex); } stagedInserts.length = 0; + stagedIdMap.clear(); + if (this._invalidIndex === Infinity) { + return; + } const newDecorationIDToSpecialLine = new ArrayMap(); const newOrderedSpecialLines: CustomLine[] = []; @@ -358,7 +369,7 @@ export class LineHeightsManager { } } - private _doLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[], stagedInserts: CustomLine[]): void { + private _doLinesInserted(fromLineNumber: number, toLineNumber: number, stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { const insertCount = toLineNumber - fromLineNumber + 1; const candidateStartIndexOfInsertion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); let startIndexOfInsertion: number; @@ -374,22 +385,6 @@ export class LineHeightsManager { } else { startIndexOfInsertion = -(candidateStartIndexOfInsertion + 1); } - const maxLineHeightPerLine = new Map(); - for (const lineHeightAdded of lineHeightsAdded) { - for (let lineNumber = lineHeightAdded.startLineNumber; lineNumber <= lineHeightAdded.endLineNumber; lineNumber++) { - if (lineNumber >= fromLineNumber && lineNumber <= toLineNumber) { - const currentMax = maxLineHeightPerLine.get(lineNumber) ?? this._defaultLineHeight; - maxLineHeightPerLine.set(lineNumber, Math.max(currentMax, lineHeightAdded.lineHeight)); - } - } - this._doInsertOrChangeCustomLineHeight( - lineHeightAdded.decorationId, - lineHeightAdded.startLineNumber, - lineHeightAdded.endLineNumber, - lineHeightAdded.lineHeight, - stagedInserts - ); - } const toReAdd: CustomLineHeightData[] = []; const decorationsImmediatelyAfter = new Set(); for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { @@ -404,9 +399,7 @@ export class LineHeightsManager { } } const decorationsWithGaps = intersection(decorationsImmediatelyBefore, decorationsImmediatelyAfter); - const specialHeightToAdd = Array.from(maxLineHeightPerLine.values()).reduce((acc, height) => acc + height, 0); - const defaultHeightToAdd = (insertCount - maxLineHeightPerLine.size) * this._defaultLineHeight; - const prefixSumToAdd = specialHeightToAdd + defaultHeightToAdd; + const prefixSumToAdd = insertCount * this._defaultLineHeight; for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { this._orderedCustomLines[i].lineNumber += insertCount; this._orderedCustomLines[i].prefixSum += prefixSumToAdd; @@ -429,7 +422,7 @@ export class LineHeightsManager { } for (const dec of toReAdd) { - this._doInsertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight, stagedInserts); + this._doInsertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight, stagedInserts, stagedIdMap); } } } @@ -493,4 +486,8 @@ class ArrayMap { delete(key: K): void { this._map.delete(key); } + + clear(): void { + this._map.clear(); + } } diff --git a/src/vs/editor/common/viewLayout/linesLayout.ts b/src/vs/editor/common/viewLayout/linesLayout.ts index cd69d95877d8f..033b7423db4c2 100644 --- a/src/vs/editor/common/viewLayout/linesLayout.ts +++ b/src/vs/editor/common/viewLayout/linesLayout.ts @@ -349,9 +349,8 @@ export class LinesLayout { * * @param fromLineNumber The line number at which the insertion started, inclusive * @param toLineNumber The line number at which the insertion ended, inclusive. - * @param lineHeightsAdded The custom line height data for the inserted lines. */ - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { fromLineNumber = fromLineNumber | 0; toLineNumber = toLineNumber | 0; @@ -363,7 +362,7 @@ export class LinesLayout { this._arr[i].afterLineNumber += (toLineNumber - fromLineNumber + 1); } } - this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); + this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber); } /** diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 048dc241eae7d..202187a4aadb2 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -243,8 +243,8 @@ export class ViewLayout extends Disposable implements IViewLayout { public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { this._linesLayout.onLinesDeleted(fromLineNumber, toLineNumber); } - public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { - this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); + public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber); } // ---- end view event handlers diff --git a/src/vs/editor/common/viewModel.ts b/src/vs/editor/common/viewModel.ts index 37ccca993c4f6..94d4488ec9b28 100644 --- a/src/vs/editor/common/viewModel.ts +++ b/src/vs/editor/common/viewModel.ts @@ -15,7 +15,7 @@ import { CursorChangeReason } from './cursorEvents.js'; import { INewScrollPosition, ScrollType } from './editorCommon.js'; import { EditorTheme } from './editorTheme.js'; import { EndOfLinePreference, IGlyphMarginLanesModel, IModelDecorationOptions, ITextModel, TextDirection } from './model.js'; -import { ILineBreaksComputer, InjectedText } from './modelLineProjectionData.js'; +import { ILineBreaksComputer, ILineBreaksComputerContext, InjectedText } from './modelLineProjectionData.js'; import { InternalModelContentChangeEvent, ModelInjectedTextChangedEvent } from './textModelEvents.js'; import { BracketGuideOptions, IActiveIndentGuideInfo, IndentGuide } from './textModelGuides.js'; import { IViewLineTokens } from './tokens/lineTokens.js'; @@ -89,7 +89,7 @@ export interface IViewModel extends ICursorSimpleModel, ISimpleModel { onDidChangeContentOrInjectedText(e: InternalModelContentChangeEvent | ModelInjectedTextChangedEvent): void; emitContentChangeEvent(e: InternalModelContentChangeEvent | ModelInjectedTextChangedEvent): void; - createLineBreaksComputer(): ILineBreaksComputer; + createLineBreaksComputer(context?: ILineBreaksComputerContext): ILineBreaksComputer; //#region cursor getPrimaryCursorState(): CursorState; diff --git a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts index 45473f77c4dec..72ea2bcd16bcc 100644 --- a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts +++ b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts @@ -38,7 +38,10 @@ export class MinimapTokensColorTracker extends Disposable { private _updateColorMap(): void { const colorMap = TokenizationRegistry.getColorMap(); if (!colorMap) { - this._colors = [RGBA8.Empty]; + this._colors = []; + for (let i = 0; i <= ColorId.DefaultBackground; i++) { + this._colors[i] = RGBA8.Empty; + } this._backgroundIsLight = true; return; } diff --git a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts index 37fba22662555..678c350916de2 100644 --- a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts +++ b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts @@ -10,7 +10,7 @@ import { CharacterClassifier } from '../core/characterClassifier.js'; import { FontInfo } from '../config/fontInfo.js'; import { LineInjectedText } from '../textModelEvents.js'; import { InjectedTextOptions } from '../model.js'; -import { ILineBreaksComputerFactory, ILineBreaksComputer, ModelLineProjectionData } from '../modelLineProjectionData.js'; +import { ILineBreaksComputerFactory, ILineBreaksComputer, ModelLineProjectionData, ILineBreaksComputerContext } from '../modelLineProjectionData.js'; export class MonospaceLineBreaksComputerFactory implements ILineBreaksComputerFactory { public static create(options: IComputedEditorOptions): MonospaceLineBreaksComputerFactory { @@ -26,23 +26,22 @@ export class MonospaceLineBreaksComputerFactory implements ILineBreaksComputerFa this.classifier = new WrappingCharacterClassifier(breakBeforeChars, breakAfterChars); } - public createLineBreaksComputer(fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer { - const requests: string[] = []; - const injectedTexts: (LineInjectedText[] | null)[] = []; + public createLineBreaksComputer(context: ILineBreaksComputerContext, fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ILineBreaksComputer { + const lineNumbers: number[] = []; const previousBreakingData: (ModelLineProjectionData | null)[] = []; return { - addRequest: (lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null) => { - requests.push(lineText); - injectedTexts.push(injectedText); + addRequest: (lineNumber: number, previousLineBreakData: ModelLineProjectionData | null) => { + lineNumbers.push(lineNumber); previousBreakingData.push(previousLineBreakData); }, finalize: () => { const columnsForFullWidthChar = fontInfo.typicalFullwidthCharacterWidth / fontInfo.typicalHalfwidthCharacterWidth; const result: (ModelLineProjectionData | null)[] = []; - for (let i = 0, len = requests.length; i < len; i++) { - const injectedText = injectedTexts[i]; + for (let i = 0, len = lineNumbers.length; i < len; i++) { + const lineNumber = lineNumbers[i]; + const injectedText = context.getLineInjectedText(lineNumber); + const lineText = context.getLineContent(lineNumber); const previousLineBreakData = previousBreakingData[i]; - const lineText = requests[i]; const isLineFeedWrappingEnabled = wrapOnEscapedLineFeeds && lineText.includes('"') && lineText.includes('\\n'); if (previousLineBreakData && !previousLineBreakData.injectionOptions && !injectedText && !isLineFeedWrappingEnabled) { result[i] = createLineBreaksFromPreviousLineBreaks(this.classifier, previousLineBreakData, lineText, tabSize, wrappingColumn, columnsForFullWidthChar, wrappingIndent, wordBreak); diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 299fb151446f4..abbd44aa3966c 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -33,7 +33,7 @@ import { EditorTheme } from '../editorTheme.js'; import * as viewEvents from '../viewEvents.js'; import { ViewLayout } from '../viewLayout/viewLayout.js'; import { MinimapTokensColorTracker } from './minimapTokensColorTracker.js'; -import { ILineBreaksComputer, ILineBreaksComputerFactory, InjectedText } from '../modelLineProjectionData.js'; +import { ILineBreaksComputer, ILineBreaksComputerContext, ILineBreaksComputerFactory, InjectedText } from '../modelLineProjectionData.js'; import { ViewEventHandler } from '../viewEventHandler.js'; import { ILineHeightChangeAccessor, IViewModel, IWhitespaceChangeAccessor, MinimapLinesRenderingData, OverviewRulerDecorationsGroup, ViewLineData, ViewLineRenderingData, ViewModelDecoration } from '../viewModel.js'; import { ViewModelDecorations } from './viewModelDecorations.js'; @@ -184,8 +184,8 @@ export class ViewModel extends Disposable implements IViewModel { return this._configuration.options.get(id); } - public createLineBreaksComputer(): ILineBreaksComputer { - return this._lines.createLineBreaksComputer(); + public createLineBreaksComputer(context?: ILineBreaksComputerContext): ILineBreaksComputer { + return this._lines.createLineBreaksComputer(context); } public addViewEventHandler(eventHandler: ViewEventHandler): void { @@ -332,22 +332,13 @@ export class ViewModel extends Disposable implements IViewModel { for (const change of changes) { switch (change.changeType) { case textModelEvents.RawContentChangedType.LinesInserted: { - for (let lineIdx = 0; lineIdx < change.detail.length; lineIdx++) { - const line = change.detail[lineIdx]; - let injectedText = change.injectedTexts[lineIdx]; - if (injectedText) { - injectedText = injectedText.filter(element => (!element.ownerId || element.ownerId === this._editorId)); - } - lineBreaksComputer.addRequest(line, injectedText, null); + for (let i = 0; i < change.count; i++) { + lineBreaksComputer.addRequest(change.fromLineNumberPostEdit + i, null); } break; } case textModelEvents.RawContentChangedType.LineChanged: { - let injectedText: textModelEvents.LineInjectedText[] | null = null; - if (change.injectedText) { - injectedText = change.injectedText.filter(element => (!element.ownerId || element.ownerId === this._editorId)); - } - lineBreaksComputer.addRequest(change.detail, injectedText, null); + lineBreaksComputer.addRequest(change.lineNumberPostEdit, null); break; } } @@ -355,6 +346,11 @@ export class ViewModel extends Disposable implements IViewModel { const lineBreaks = lineBreaksComputer.finalize(); const lineBreakQueue = new ArrayQueue(lineBreaks); + // Collect model line ranges that need custom line height computation. + // We defer this until after the loop because the coordinatesConverter + // relies on projections that may not yet reflect all changes in the batch. + const customLineHeightRangesToInsert: { fromLineNumber: number; toLineNumber: number }[] = []; + for (const change of changes) { switch (change.changeType) { case textModelEvents.RawContentChangedType.Flush: { @@ -370,16 +366,18 @@ export class ViewModel extends Disposable implements IViewModel { if (linesDeletedEvent !== null) { eventsCollector.emitViewEvent(linesDeletedEvent); this.viewLayout.onLinesDeleted(linesDeletedEvent.fromLineNumber, linesDeletedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lastUntouchedLinePostEdit, toLineNumber: change.lastUntouchedLinePostEdit }); } hadOtherModelChange = true; break; } case textModelEvents.RawContentChangedType.LinesInserted: { - const insertedLineBreaks = lineBreakQueue.takeCount(change.detail.length); + const insertedLineBreaks = lineBreakQueue.takeCount(change.count); const linesInsertedEvent = this._lines.onModelLinesInserted(versionId, change.fromLineNumber, change.toLineNumber, insertedLineBreaks); if (linesInsertedEvent !== null) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.fromLineNumberPostEdit, change.toLineNumberPostEdit)); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.fromLineNumberPostEdit, toLineNumber: change.toLineNumberPostEdit }); } hadOtherModelChange = true; break; @@ -394,11 +392,13 @@ export class ViewModel extends Disposable implements IViewModel { } if (linesInsertedEvent) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.lineNumberPostEdit, change.lineNumberPostEdit)); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lineNumberPostEdit, toLineNumber: change.lineNumberPostEdit }); } if (linesDeletedEvent) { eventsCollector.emitViewEvent(linesDeletedEvent); this.viewLayout.onLinesDeleted(linesDeletedEvent.fromLineNumber, linesDeletedEvent.toLineNumber); + customLineHeightRangesToInsert.push({ fromLineNumber: change.lineNumberPostEdit, toLineNumber: change.lineNumberPostEdit }); } break; } @@ -412,6 +412,19 @@ export class ViewModel extends Disposable implements IViewModel { if (versionId !== null) { this._lines.acceptVersionId(versionId); } + + // Apply deferred custom line heights now that projections are stable + if (customLineHeightRangesToInsert.length > 0) { + this.viewLayout.changeSpecialLineHeights((accessor: ILineHeightChangeAccessor) => { + for (const range of customLineHeightRangesToInsert) { + const customLineHeights = this._getCustomLineHeightsForLines(range.fromLineNumber, range.toLineNumber); + for (const data of customLineHeights) { + accessor.insertOrChangeCustomLineHeight(data.decorationId, data.startLineNumber, data.endLineNumber, data.lineHeight); + } + } + }); + } + this.viewLayout.onHeightMaybeChanged(); if (!hadOtherModelChange && hadModelLineChangeThatChangedLineMapping) { diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index b3721760c9d4e..199e787f88235 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as arrays from '../../../base/common/arrays.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { WrappingIndent } from '../config/editorOptions.js'; import { FontInfo } from '../config/fontInfo.js'; @@ -12,13 +11,13 @@ import { Range } from '../core/range.js'; import { IModelDecoration, IModelDeltaDecoration, ITextModel, PositionAffinity } from '../model.js'; import { IActiveIndentGuideInfo, BracketGuideOptions, IndentGuide, IndentGuideHorizontalLine } from '../textModelGuides.js'; import { ModelDecorationOptions } from '../model/textModel.js'; -import { LineInjectedText } from '../textModelEvents.js'; import * as viewEvents from '../viewEvents.js'; import { createModelLineProjection, IModelLineProjection } from './modelLineProjection.js'; -import { ILineBreaksComputer, ModelLineProjectionData, InjectedText, ILineBreaksComputerFactory } from '../modelLineProjectionData.js'; +import { ILineBreaksComputer, ModelLineProjectionData, InjectedText, ILineBreaksComputerFactory, ILineBreaksComputerContext } from '../modelLineProjectionData.js'; import { ConstantTimePrefixSumComputer } from '../model/prefixSumComputer.js'; import { ViewLineData } from '../viewModel.js'; import { ICoordinatesConverter, IdentityCoordinatesConverter } from '../coordinatesConverter.js'; +import { LineInjectedText } from '../textModelEvents.js'; export interface IViewModelLines extends IDisposable { createCoordinatesConverter(): ICoordinatesConverter; @@ -28,7 +27,7 @@ export interface IViewModelLines extends IDisposable { getHiddenAreas(): Range[]; setHiddenAreas(_ranges: readonly Range[]): boolean; - createLineBreaksComputer(): ILineBreaksComputer; + createLineBreaksComputer(context?: ILineBreaksComputerContext): ILineBreaksComputer; onModelFlushed(): void; onModelLinesDeleted(versionId: number | null, fromLineNumber: number, toLineNumber: number): viewEvents.ViewLinesDeletedEvent | null; onModelLinesInserted(versionId: number | null, fromLineNumber: number, toLineNumber: number, lineBreaks: (ModelLineProjectionData | null)[]): viewEvents.ViewLinesInsertedEvent | null; @@ -128,14 +127,11 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { } const linesContent = this.model.getLinesContent(); - const injectedTextDecorations = this.model.getInjectedTextDecorations(this._editorId); const lineCount = linesContent.length; const lineBreaksComputer = this.createLineBreaksComputer(); - const injectedTextQueue = new arrays.ArrayQueue(LineInjectedText.fromDecorations(injectedTextDecorations)); for (let i = 0; i < lineCount; i++) { - const lineInjectedText = injectedTextQueue.takeWhile(t => t.lineNumber === i + 1); - lineBreaksComputer.addRequest(linesContent[i], lineInjectedText, previousLineBreaks ? previousLineBreaks[i] : null); + lineBreaksComputer.addRequest(i + 1, previousLineBreaks ? previousLineBreaks[i] : null); } const linesBreaks = lineBreaksComputer.finalize(); @@ -309,13 +305,21 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { return true; } - public createLineBreaksComputer(): ILineBreaksComputer { + public createLineBreaksComputer(_context?: ILineBreaksComputerContext): ILineBreaksComputer { const lineBreaksComputerFactory = ( this.wrappingStrategy === 'advanced' ? this._domLineBreaksComputerFactory : this._monospaceLineBreaksComputerFactory ); - return lineBreaksComputerFactory.createLineBreaksComputer(this.fontInfo, this.tabSize, this.wrappingColumn, this.wrappingIndent, this.wordBreak, this.wrapOnEscapedLineFeeds); + const context: ILineBreaksComputerContext = _context ?? { + getLineContent: (lineNumber: number): string => { + return this.model.getLineContent(lineNumber); + }, + getLineInjectedText: (lineNumber: number): LineInjectedText[] => { + return this.model.getLineInjectedText(lineNumber, this._editorId); + } + }; + return lineBreaksComputerFactory.createLineBreaksComputer(context, this.fontInfo, this.tabSize, this.wrappingColumn, this.wrappingIndent, this.wordBreak, this.wrapOnEscapedLineFeeds); } public onModelFlushed(): void { @@ -1153,7 +1157,7 @@ export class ViewModelLinesFromModelAsIs implements IViewModelLines { public createLineBreaksComputer(): ILineBreaksComputer { const result: null[] = []; return { - addRequest: (lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null) => { + addRequest: (lineNumber: number, previousLineBreakData: ModelLineProjectionData | null) => { result.push(null); }, finalize: () => { diff --git a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts index a1b8b00bd48b3..6bcb8223428ab 100644 --- a/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts +++ b/src/vs/editor/contrib/contextmenu/browser/contextmenu.ts @@ -348,6 +348,19 @@ export class ContextMenuController implements IEditorContribution { value: 'always' }] )); + actions.push(createEnumAction<'right' | 'left'>( + nls.localize('context.minimap.side', "Side"), + minimapOptions.enabled, + 'editor.minimap.side', + minimapOptions.side, + [{ + label: nls.localize('context.minimap.side.right', "Right"), + value: 'right' + }, { + label: nls.localize('context.minimap.side.left', "Left"), + value: 'left' + }] + )); const useShadowDOM = this._editor.getOption(EditorOption.useShadowDOM) && !isIOS; // Do not use shadow dom on IOS #122035 this._contextMenuIsBeingShownCount++; diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts index 79e5132ede98f..f02d8efdb8c27 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/copyPasteController.ts @@ -657,7 +657,7 @@ export class CopyPasteController extends Disposable implements IEditorContributi if ('only' in preference) { return provider.providedPasteEditKinds.some(providedKind => preference.only.contains(providedKind)); } else if ('preferences' in preference) { - return preference.preferences.some(providedKind => preference.preferences.some(preferredKind => preferredKind.contains(providedKind))); + return provider.providedPasteEditKinds.some(providedKind => preference.preferences.some(preferredKind => preferredKind.contains(providedKind))); } else { return provider.id === preference.providerId; } diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css index 496b989268e8f..cce094ae6dc5f 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .post-edit-widget { - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border: 1px solid var(--vscode-widget-border, transparent); border-radius: 4px; color: var(--vscode-button-foreground); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index ec2ee490e1980..c504148633d2e 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { alert as alertFn } from '../../../../base/browser/ui/aria/aria.js'; import { Delayer } from '../../../../base/common/async.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -18,7 +19,7 @@ import { OverviewRulerLane } from '../../../common/model.js'; import { CONTEXT_FIND_INPUT_FOCUSED, CONTEXT_FIND_WIDGET_VISIBLE, CONTEXT_REPLACE_INPUT_FOCUSED, FindModelBoundToEditorModel, FIND_IDS, ToggleCaseSensitiveKeybinding, TogglePreserveCaseKeybinding, ToggleRegexKeybinding, ToggleSearchScopeKeybinding, ToggleWholeWordKeybinding } from './findModel.js'; import { FindOptionsWidget } from './findOptionsWidget.js'; import { FindReplaceState, FindReplaceStateChangedEvent, INewFindReplaceState } from './findState.js'; -import { FindWidget, IFindController } from './findWidget.js'; +import { FindWidget, IFindController, NLS_NO_RESULTS } from './findWidget.js'; import * as nls from '../../../../nls.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; @@ -725,11 +726,28 @@ async function matchFindAction(editor: ICodeEditor, next: boolean): Promise { + const previousSelection = controller.editor.getSelection(); const result = next ? controller.moveToNextMatch() : controller.moveToPrevMatch(); + + let landedOnMatch = false; if (result) { + const currentSelection = controller.editor.getSelection(); + if (!previousSelection && currentSelection) { + landedOnMatch = true; + } else if (previousSelection && currentSelection && !previousSelection.equalsSelection(currentSelection)) { + landedOnMatch = true; + } + } + + if (landedOnMatch) { controller.editor.pushUndoStop(); + if (shouldCloseOnResult && wasFindWidgetVisible && controller.isFindInputFocused()) { + controller.closeFindWidget(); + } return true; } return false; @@ -746,7 +764,13 @@ async function matchFindAction(editor: ICodeEditor, next: boolean): Promise { }); }); + test('editor.find.closeOnResult: closes find widget when a match is found from explicit navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, false); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: keeps find widget open when no match is found', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'DEF', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'NO_MATCH' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.matchesCount, 0); + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: disabled keeps find widget open after navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: false } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + test('issue #9043: Clear search scope when find widget is hidden', async () => { await withAsyncTestCodeEditor([ 'var x = (3 * 5)', diff --git a/src/vs/editor/contrib/find/test/browser/findModel.test.ts b/src/vs/editor/contrib/find/test/browser/findModel.test.ts index 09a0de5a7ae5f..8e2bbc3ccd362 100644 --- a/src/vs/editor/contrib/find/test/browser/findModel.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findModel.test.ts @@ -2383,4 +2383,23 @@ suite('FindModel', () => { }); + test('issue #288515: Wrong current index in find widget if matches > 1000', () => { + // Create 1001 lines of 'hello' + const textArr = Array(1001).fill('hello'); + withTestCodeEditor(textArr, {}, (_editor) => { + const editor = _editor as IActiveCodeEditor; + + // Place cursor at line 900, selecting 'hello' + editor.setSelection(new Selection(900, 1, 900, 6)); + + const findState = disposables.add(new FindReplaceState()); + findState.change({ searchString: 'hello' }, false); + disposables.add(new FindModelBoundToEditorModel(editor, findState)); + + assert.strictEqual(findState.matchesCount, 1001); + // With cursor selecting 'hello' at line 900, matchesPosition should be 900 + assert.strictEqual(findState.matchesPosition, 900); + }); + }); + }); diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css index 422e073e5e739..2182ed732e6df 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css @@ -7,16 +7,39 @@ padding: 2px 4px; color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); border: 1px solid var(--vscode-contrastBorder); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; + &.single-button { + background-color: transparent; + border-width: 0; + padding: 0; + overflow: visible; + + .action-item > .action-label, + .action-item > .action-label.codicon:not(.separator) { + height: 28px; + line-height: 28px; + border-radius: var(--vscode-cornerRadius-medium); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + } + + .action-item > .action-label { + padding: 0 8px; + } + + .action-item > .action-label.codicon:not(.separator) { + width: 28px; + } + } + .actions-container { gap: 4px; } @@ -25,7 +48,7 @@ padding: 4px 6px; font-size: 11px; line-height: 14px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .action-item > .action-label.codicon:not(.separator) { @@ -50,3 +73,16 @@ background-color: var(--vscode-button-hoverBackground) !important; } } + +.hc-black .floating-menu-overlay-widget.single-button, +.hc-light .floating-menu-overlay-widget.single-button { + border-width: 1px; + border-style: solid; + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editorWidget-background); + padding: 0; + .action-item > .action-label, + .action-item > .action-label.codicon:not(.separator) { + box-shadow: none; + } +} diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts index 1a530186e669f..5e7be22f374a7 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Separator } from '../../../../base/common/actions.js'; import { h } from '../../../../base/browser/dom.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { getActionBarActions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { autorun, constObservable, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; +import { getActionBarActions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -75,25 +76,27 @@ export class FloatingEditorToolbarWidget extends Disposable { const menu = this._register(menuService.createMenu(_menuId, _scopedContextKeyService)); const menuGroupsObs = observableFromEvent(this, menu.onDidChange, () => menu.getActions()); - const menuPrimaryActionIdObs = derived(reader => { + const menuPrimaryActionsObs = derived(reader => { const menuGroups = menuGroupsObs.read(reader); - const { primary } = getActionBarActions(menuGroups, () => true); - return primary.length > 0 ? primary[0].id : undefined; + return primary.filter(a => a.id !== Separator.ID); }); - this.hasActions = derived(reader => menuGroupsObs.read(reader).length > 0); + this.hasActions = derived(reader => menuPrimaryActionsObs.read(reader).length > 0); this.element = h('div.floating-menu-overlay-widget').root; this._register(toDisposable(() => this.element.remove())); - // Set height explicitly to ensure that the floating menu element - // is rendered in the lower right corner at the correct position. - this.element.style.height = '26px'; - this._register(autorun(reader => { - const hasActions = this.hasActions.read(reader); - const menuPrimaryActionId = menuPrimaryActionIdObs.read(reader); + const primaryActions = menuPrimaryActionsObs.read(reader); + const hasActions = primaryActions.length > 0; + const menuPrimaryActionId = hasActions ? primaryActions[0].id : undefined; + + const isSingleButton = primaryActions.length === 1; + this.element.classList.toggle('single-button', isSingleButton); + // Set height explicitly to ensure that the floating menu element + // is rendered in the lower right corner at the correct position. + this.element.style.height = isSingleButton ? '28px' : '26px'; if (!hasActions) { return; diff --git a/src/vs/editor/contrib/hover/browser/hover.css b/src/vs/editor/contrib/hover/browser/hover.css index aedcb6944b324..269ee853c7ec2 100644 --- a/src/vs/editor/contrib/hover/browser/hover.css +++ b/src/vs/editor/contrib/hover/browser/hover.css @@ -9,20 +9,23 @@ .monaco-editor .monaco-resizable-hover { border: 1px solid var(--vscode-editorHoverWidget-border); - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-large); box-sizing: content-box; + background-color: var(--vscode-editorHoverWidget-background); } .monaco-editor .monaco-resizable-hover > .monaco-hover { border: none; - border-radius: unset; + border-radius: inherit; + overflow: hidden; } .monaco-editor .monaco-hover { border: 1px solid var(--vscode-editorHoverWidget-border); - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-large); color: var(--vscode-editorHoverWidget-foreground); background-color: var(--vscode-editorHoverWidget-background); + box-shadow: var(--vscode-shadow-hover); } .monaco-editor .monaco-hover a { @@ -34,6 +37,7 @@ } .monaco-editor .monaco-hover .hover-row { + border-radius: var(--vscode-cornerRadius-large); display: flex; } diff --git a/src/vs/editor/contrib/hover/browser/hoverActionIds.ts b/src/vs/editor/contrib/hover/browser/hoverActionIds.ts index 75b3930c79171..26eb23b7bb969 100644 --- a/src/vs/editor/contrib/hover/browser/hoverActionIds.ts +++ b/src/vs/editor/contrib/hover/browser/hoverActionIds.ts @@ -21,3 +21,4 @@ export const INCREASE_HOVER_VERBOSITY_ACTION_LABEL = nls.localize({ key: 'increa export const DECREASE_HOVER_VERBOSITY_ACTION_ID = 'editor.action.decreaseHoverVerbosityLevel'; export const DECREASE_HOVER_VERBOSITY_ACCESSIBLE_ACTION_ID = 'editor.action.decreaseHoverVerbosityLevelFromAccessibleView'; export const DECREASE_HOVER_VERBOSITY_ACTION_LABEL = nls.localize({ key: 'decreaseHoverVerbosityLevel', comment: ['Label for action that will decrease the hover verbosity level.'] }, "Decrease Hover Verbosity Level"); +export const HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID = 'editor.action.hideLongLineWarningHover'; diff --git a/src/vs/editor/contrib/hover/browser/hoverContribution.ts b/src/vs/editor/contrib/hover/browser/hoverContribution.ts index 9e4168a1be08b..678ffe4908d95 100644 --- a/src/vs/editor/contrib/hover/browser/hoverContribution.ts +++ b/src/vs/editor/contrib/hover/browser/hoverContribution.ts @@ -7,6 +7,9 @@ import { DecreaseHoverVerbosityLevel, GoToBottomHoverAction, GoToTopHoverAction, import { EditorContributionInstantiation, registerEditorAction, registerEditorContribution } from '../../../browser/editorExtensions.js'; import { editorHoverBorder } from '../../../../platform/theme/common/colorRegistry.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID } from './hoverActionIds.js'; import { HoverParticipantRegistry } from './hoverTypes.js'; import { MarkdownHoverParticipant } from './markdownHoverParticipant.js'; import { MarkerHoverParticipant } from './markerHoverParticipant.js'; @@ -33,6 +36,9 @@ registerEditorAction(IncreaseHoverVerbosityLevel); registerEditorAction(DecreaseHoverVerbosityLevel); HoverParticipantRegistry.register(MarkdownHoverParticipant); HoverParticipantRegistry.register(MarkerHoverParticipant); +CommandsRegistry.registerCommand(HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID, (accessor) => { + accessor.get(IConfigurationService).updateValue('editor.hover.showLongLineWarning', false); +}); // theming registerThemingParticipant((theme, collector) => { diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index ba261eaa4a44a..9cd72e14497e4 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { IMarkdownString, isEmptyMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; -import { DECREASE_HOVER_VERBOSITY_ACTION_ID, INCREASE_HOVER_VERBOSITY_ACTION_ID } from './hoverActionIds.js'; +import { DECREASE_HOVER_VERBOSITY_ACTION_ID, INCREASE_HOVER_VERBOSITY_ACTION_ID, HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID } from './hoverActionIds.js'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; import { Position } from '../../../common/core/position.js'; import { Range } from '../../../common/core/range.js'; @@ -115,17 +115,32 @@ export class MarkdownHoverParticipant implements IEditorHoverParticipant('editor.maxTokenizationLineLength', { overrideIdentifier: languageId }); + const showLongLineWarning = this._editor.getOption(EditorOption.hover).showLongLineWarning; let stopRenderingMessage = false; if (stopRenderingLineAfter >= 0 && lineLength > stopRenderingLineAfter && anchor.range.startColumn >= stopRenderingLineAfter) { stopRenderingMessage = true; - result.push(new MarkdownHover(this, anchor.range, [{ - value: nls.localize('stopped rendering', "Rendering paused for long line for performance reasons. This can be configured via `editor.stopRenderingLineAfter`.") - }], false, index++)); + if (showLongLineWarning) { + result.push(new MarkdownHover(this, anchor.range, [{ + value: nls.localize( + { key: 'stopped rendering', comment: ['Please do not translate the word "command", it is part of our internal syntax which must not change', '{Locked="](command:{0})"}'] }, + "Rendering paused for long line for performance reasons. This can be configured via `editor.stopRenderingLineAfter`. [Don't Show Again](command:{0})", + HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID + ), + isTrusted: true + }], false, index++)); + } } if (!stopRenderingMessage && typeof maxTokenizationLineLength === 'number' && lineLength >= maxTokenizationLineLength) { - result.push(new MarkdownHover(this, anchor.range, [{ - value: nls.localize('too many characters', "Tokenization is skipped for long lines for performance reasons. This can be configured via `editor.maxTokenizationLineLength`.") - }], false, index++)); + if (showLongLineWarning) { + result.push(new MarkdownHover(this, anchor.range, [{ + value: nls.localize( + { key: 'too many characters', comment: ['Please do not translate the word "command", it is part of our internal syntax which must not change', '{Locked="](command:{0})"}'] }, + "Tokenization is skipped for long lines for performance reasons. This can be configured via `editor.maxTokenizationLineLength`. [Don't Show Again](command:{0})", + HIDE_LONG_LINE_WARNING_HOVER_ACTION_ID + ), + isTrusted: true + }], false, index++)); + } } let isBeforeContent = false; diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index c9f7c4479de11..4964af49280ac 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -22,6 +22,8 @@ import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSour import { MarkerController, NextMarkerAction } from '../../gotoError/browser/gotoError.js'; import { HoverAnchor, HoverAnchorType, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverPart, IRenderedHoverParts, RenderedHoverParts } from './hoverTypes.js'; import * as nls from '../../../../nls.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IMarker, IMarkerData, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -65,6 +67,8 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { + for (const action of menuActions) { + context.statusBar.addAction({ + label: action.label, + commandId: action.id, + iconClass: action.class, + run: () => { + context.hide(); + this._editor.setSelection(Range.lift(markerHover.range)); + action.run(); + } + }); + } + }; + if (!this._editor.getOption(EditorOption.readOnly)) { const quickfixPlaceholderElement = context.statusBar.append($('div')); if (this.recentMarkerCodeActionsInfo) { if (IMarkerData.makeKey(this.recentMarkerCodeActionsInfo.marker) === IMarkerData.makeKey(markerHover.marker)) { if (!this.recentMarkerCodeActionsInfo.hasCodeActions) { - quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + if (menuActions.length === 0) { + quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + } } } else { this.recentMarkerCodeActionsInfo = undefined; @@ -230,7 +260,12 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant( onDidChangeDefaultAccount: Event.None, onDidChangePolicyData: Event.None, policyData: null, + copilotTokenInfo: null, + onDidChangeCopilotTokenInfo: Event.None, getDefaultAccount: async () => null, setDefaultAccountProvider: () => { }, getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, diff --git a/src/vs/editor/contrib/parameterHints/browser/parameterHints.css b/src/vs/editor/contrib/parameterHints/browser/parameterHints.css index 3efac6c122caa..31dcb1d38a9d5 100644 --- a/src/vs/editor/contrib/parameterHints/browser/parameterHints.css +++ b/src/vs/editor/contrib/parameterHints/browser/parameterHints.css @@ -13,6 +13,8 @@ color: var(--vscode-editorHoverWidget-foreground); background-color: var(--vscode-editorHoverWidget-background); border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .hc-black .monaco-editor .parameter-hints-widget, @@ -76,6 +78,9 @@ .monaco-editor .parameter-hints-widget .docs { padding: 0 10px 0 5px; white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + min-width: 0; } .monaco-editor .parameter-hints-widget .docs.empty { @@ -93,6 +98,9 @@ .monaco-editor .parameter-hints-widget .docs .markdown-docs { white-space: initial; + overflow-wrap: break-word; + word-break: break-word; + max-width: 100%; } .monaco-editor .parameter-hints-widget .docs code { diff --git a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css index f49973bfea3fe..eb777d9ac7f42 100644 --- a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css +++ b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-editor .peekview-widget { + box-shadow: var(--vscode-shadow-hover); +} + .monaco-editor .peekview-widget .head { box-sizing: border-box; display: flex; diff --git a/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts b/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts index b4f95f7158ef9..a73ba22236764 100644 --- a/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts +++ b/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts @@ -18,7 +18,7 @@ import { DocumentSymbol, SymbolKind, SymbolKinds, SymbolTag, getAriaLabelForSymb import { IOutlineModelService } from '../../documentSymbols/browser/outlineModel.js'; import { AbstractEditorNavigationQuickAccessProvider, IEditorNavigationQuickAccessOptions, IQuickAccessTextEditorContext } from './editorNavigationQuickAccess.js'; import { localize } from '../../../../nls.js'; -import { IQuickInputButton, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IKeyMods, IQuickInputButton, IQuickPick, IQuickPickDidAcceptEvent, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js'; import { Position } from '../../../common/core/position.js'; import { findLast } from '../../../../base/common/arraysFind.js'; @@ -32,6 +32,7 @@ export interface IGotoSymbolQuickPickItem extends IQuickPickItem { uri?: URI; symbolName?: string; range?: { decoration: IRange; selection: IRange }; + attach?(keyMods: IKeyMods, event: IQuickPickDidAcceptEvent): void; } export interface IGotoSymbolQuickAccessProviderOptions extends IEditorNavigationQuickAccessOptions { @@ -145,6 +146,13 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit disposables.add(picker.onDidAccept(event => { const [item] = picker.selectedItems; if (item && item.range) { + // When shift is held and attach is available, delegate to attach + // (e.g. to add to chat context) instead of navigating + if (picker.keyMods.shift && item.attach) { + item.attach(picker.keyMods, event); + return; + } + this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, preserveFocus: event.inBackground }); runOptions?.handleAccept?.(item, event.inBackground); diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.css b/src/vs/editor/contrib/rename/browser/renameWidget.css index acd375f2afb7e..730bf8895b8fc 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.css +++ b/src/vs/editor/contrib/rename/browser/renameWidget.css @@ -7,6 +7,7 @@ z-index: 100; color: inherit; border-radius: 4px; + box-shadow: var(--vscode-shadow-hover); } .monaco-editor .rename-box.preview { diff --git a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts index 2bc7cab868a76..95348d44230af 100644 --- a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts +++ b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts @@ -6,7 +6,7 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import * as errors from '../../../../base/common/errors.js'; -import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -27,6 +27,7 @@ import { SEMANTIC_HIGHLIGHTING_SETTING_ID, isSemanticColoringEnabled } from '../ export class DocumentSemanticTokensFeature extends Disposable { private readonly _watchers = new ResourceMap(); + private readonly _providerChangeListeners = this._register(new DisposableStore()); constructor( @ISemanticTokensStylingService semanticTokensStylingService: ISemanticTokensStylingService, @@ -38,6 +39,8 @@ export class DocumentSemanticTokensFeature extends Disposable { ) { super(); + const provider = languageFeaturesService.documentSemanticTokensProvider; + const register = (model: ITextModel) => { this._watchers.get(model.uri)?.dispose(); this._watchers.set(model.uri, new ModelSemanticColoring(model, semanticTokensStylingService, themeService, languageFeatureDebounceService, languageFeaturesService)); @@ -60,6 +63,20 @@ export class DocumentSemanticTokensFeature extends Disposable { } } }; + + const bindProviderChangeListeners = () => { + this._providerChangeListeners.clear(); + for (const p of provider.allNoModel()) { + if (typeof p.onDidChange === 'function') { + this._providerChangeListeners.add(p.onDidChange(() => { + for (const watcher of this._watchers.values()) { + watcher.handleProviderDidChange(p); + } + })); + } + } + }; + modelService.getModels().forEach(model => { if (isSemanticColoringEnabled(model, themeService, configurationService)) { register(model); @@ -82,6 +99,13 @@ export class DocumentSemanticTokensFeature extends Disposable { } })); this._register(themeService.onDidColorThemeChange(handleSettingOrThemeChange)); + bindProviderChangeListeners(); + this._register(provider.onDidChange(() => { + bindProviderChangeListeners(); + for (const watcher of this._watchers.values()) { + watcher.handleRegistryChange(); + } + })); } override dispose(): void { @@ -104,7 +128,7 @@ class ModelSemanticColoring extends Disposable { private readonly _fetchDocumentSemanticTokens: RunOnceScheduler; private _currentDocumentResponse: SemanticTokensResponse | null; private _currentDocumentRequestCancellationTokenSource: CancellationTokenSource | null; - private _documentProvidersChangeListeners: IDisposable[]; + private _relevantProviders = new Set(); private _providersChangedDuringRequest: boolean; constructor( @@ -123,8 +147,8 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens = this._register(new RunOnceScheduler(() => this._fetchDocumentSemanticTokensNow(), ModelSemanticColoring.REQUEST_MIN_DELAY)); this._currentDocumentResponse = null; this._currentDocumentRequestCancellationTokenSource = null; - this._documentProvidersChangeListeners = []; this._providersChangedDuringRequest = false; + this._updateRelevantProviders(); this._register(this._model.onDidChangeContent(() => { if (!this._fetchDocumentSemanticTokens.isScheduled()) { @@ -147,31 +171,10 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource = null; } this._setDocumentSemanticTokens(null, null, null, []); + this._updateRelevantProviders(); this._fetchDocumentSemanticTokens.schedule(0); })); - const bindDocumentChangeListeners = () => { - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; - for (const provider of this._provider.all(model)) { - if (typeof provider.onDidChange === 'function') { - this._documentProvidersChangeListeners.push(provider.onDidChange(() => { - if (this._currentDocumentRequestCancellationTokenSource) { - // there is already a request running, - this._providersChangedDuringRequest = true; - return; - } - this._fetchDocumentSemanticTokens.schedule(0); - })); - } - } - }; - bindDocumentChangeListeners(); - this._register(this._provider.onDidChange(() => { - bindDocumentChangeListeners(); - this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); - })); - this._register(themeService.onDidColorThemeChange(_ => { // clear out existing tokens this._setDocumentSemanticTokens(null, null, null, []); @@ -181,6 +184,27 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens.schedule(0); } + public handleRegistryChange(): void { + this._updateRelevantProviders(); + this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); + } + + public handleProviderDidChange(provider: DocumentSemanticTokensProvider): void { + if (!this._relevantProviders.has(provider)) { + return; + } + if (this._currentDocumentRequestCancellationTokenSource) { + // there is already a request running, + this._providersChangedDuringRequest = true; + return; + } + this._fetchDocumentSemanticTokens.schedule(0); + } + + private _updateRelevantProviders(): void { + this._relevantProviders = new Set(this._provider.all(this._model)); + } + public override dispose(): void { if (this._currentDocumentResponse) { this._currentDocumentResponse.dispose(); @@ -190,8 +214,6 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource.cancel(); this._currentDocumentRequestCancellationTokenSource = null; } - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; this._setDocumentSemanticTokens(null, null, null, []); this._isDisposed = true; diff --git a/src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts b/src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts index 93ec32ae65e28..89c480c64c4e1 100644 --- a/src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts +++ b/src/vs/editor/contrib/snippet/test/browser/snippetVariables.test.ts @@ -371,6 +371,7 @@ suite('Snippet Variables Resolver', function () { getCompleteWorkspace = this._throw; getWorkspace(): IWorkspace { return workspace; } getWorkbenchState = this._throw; + hasWorkspaceData = this._throw; getWorkspaceFolder = this._throw; isCurrentWorkspace = this._throw; isInsideWorkspace = this._throw; diff --git a/src/vs/editor/contrib/suggest/browser/media/suggest.css b/src/vs/editor/contrib/suggest/browser/media/suggest.css index 755c457fc20bd..70f27a8fa869a 100644 --- a/src/vs/editor/contrib/suggest/browser/media/suggest.css +++ b/src/vs/editor/contrib/suggest/browser/media/suggest.css @@ -10,7 +10,8 @@ z-index: 40; display: flex; flex-direction: column; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .suggest-widget.message { @@ -96,6 +97,7 @@ .monaco-editor .suggest-widget .monaco-list { user-select: none; -webkit-user-select: none; + border-radius: var(--vscode-cornerRadius-large); } /** Styles for each row in the list element **/ diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index 30c1276d5c4fd..595b7ef51c9f3 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimeoutTimer } from '../../../../base/common/async.js'; +import { TimeoutTimer, disposableTimeout } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -30,8 +30,10 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; import { assertType } from '../../../../base/common/types.js'; import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; +import { getInlineCompletionsController } from '../../inlineCompletions/browser/controller/common.js'; import { SnippetController2 } from '../../snippet/browser/snippetController2.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { autorun } from '../../../../base/common/observable.js'; export interface ICancelEvent { readonly retrigger: boolean; @@ -134,6 +136,7 @@ export class SuggestModel implements IDisposable { private readonly _toDispose = new DisposableStore(); private readonly _triggerCharacterListener = new DisposableStore(); private readonly _triggerQuickSuggest = new TimeoutTimer(); + private _waitForInlineCompletions: DisposableStore | undefined; private _triggerState: SuggestTriggerOptions | undefined = undefined; private _requestToken?: CancellationTokenSource; @@ -209,6 +212,7 @@ export class SuggestModel implements IDisposable { dispose(): void { dispose(this._triggerCharacterListener); dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]); + this._waitForInlineCompletions?.dispose(); this._toDispose.dispose(); this._completionDisposables.dispose(); this.cancel(); @@ -310,8 +314,11 @@ export class SuggestModel implements IDisposable { } cancel(retrigger: boolean = false): void { + this._triggerQuickSuggest.cancel(); + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + if (this._triggerState !== undefined) { - this._triggerQuickSuggest.cancel(); this._requestToken?.cancel(); this._requestToken = undefined; this._triggerState = undefined; @@ -391,6 +398,10 @@ export class SuggestModel implements IDisposable { this.cancel(); + // Cancel any in-flight wait for inline completions from a previous cycle + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + this._triggerQuickSuggest.cancelAndSet(() => { if (this._triggerState !== undefined) { return; @@ -409,16 +420,19 @@ export class SuggestModel implements IDisposable { return; } + let waitForInlineCompletions = false; if (!QuickSuggestionsOptions.isAllOn(config)) { // Check the type of the token that triggered this model.tokenization.tokenizeIfCheap(pos.lineNumber); const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' - || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { - return; - } + const value = QuickSuggestionsOptions.valueFor(config, tokenType); + if (value === 'off' || value === 'inline') { + return; + } + if (value === 'offWhenInlineCompletions') { + waitForInlineCompletions = this._languageFeaturesService.inlineCompletionsProvider.has(model) + && this._editor.getOption(EditorOption.inlineSuggest).enabled; } } @@ -431,12 +445,73 @@ export class SuggestModel implements IDisposable { return; } - // we made it till here -> trigger now - this.trigger({ auto: true }); + if (waitForInlineCompletions) { + // Wait for inline completions to resolve before deciding + this._waitForInlineCompletionsAndTrigger(model, pos); + } else { + this.trigger({ auto: true }); + } }, this._editor.getOption(EditorOption.quickSuggestionsDelay)); } + private _waitForInlineCompletionsAndTrigger(initialModel: ITextModel, initialPosition: Position): void { + const initialModelVersion = initialModel.getVersionId(); + const inlineController = getInlineCompletionsController(this._editor); + const inlineModel = inlineController?.model.get(); + if (!inlineModel) { + this.trigger({ auto: true }); + return; + } + + const state = inlineModel.state.get(); + if (state?.inlineSuggestion) { + // Inline completions are already showing - suppress + return; + } + + const store = new DisposableStore(); + this._waitForInlineCompletions = store; + + const triggerAndCleanUp = (doTrigger: boolean) => { + store.dispose(); + if (this._waitForInlineCompletions === store) { + this._waitForInlineCompletions = undefined; + } + if (this._triggerState !== undefined) { + return; + } + if (!doTrigger) { + return; + } + const currentModel = this._editor.getModel(); + const currentPosition = this._editor.getPosition(); + if (currentModel === initialModel + && currentModel.getVersionId() === initialModelVersion + && currentPosition?.equals(initialPosition) + && this._editor.hasWidgetFocus() + ) { + this.trigger({ auto: true }); + } + }; + + // Race: observe inline completions state vs 750ms timeout + disposableTimeout(() => { + triggerAndCleanUp(true); + inlineModel.stop('automatic'); + }, 750, store); + + store.add(autorun(reader => { + const status = inlineModel.status.read(reader); + const currentState = inlineModel.state.read(reader); + if (!currentState && status === 'loading') { + // Still loading + return; + } + triggerAndCleanUp(!currentState); + })); + } + private _refilterCompletionItems(): void { assertType(this._editor.hasModel()); assertType(this._triggerState !== undefined); diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index ff465706c0c28..d99d63f7c9836 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -25,7 +25,7 @@ import { SuggestController } from '../../browser/suggestController.js'; import { ISuggestMemoryService } from '../../browser/suggestMemory.js'; import { LineContext, SuggestModel } from '../../browser/suggestModel.js'; import { ISelectedSuggestion } from '../../browser/suggestWidget.js'; -import { createTestCodeEditor, ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -42,6 +42,15 @@ import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browse import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js'; +import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IMenuService, IMenu } from '../../../../../platform/actions/common/actions.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IEditorWorkerService } from '../../../../common/services/editorWorker.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1230,11 +1239,11 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + test('offWhenInlineCompletions - allows quick suggest when inline provider returns empty results', function () { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - // Register a dummy inline completions provider + // Register a dummy inline completions provider that returns no items const inlineProvider: InlineCompletionsProvider = { provideInlineCompletions: () => ({ items: [] }), disposeInlineCompletions: () => { } @@ -1244,20 +1253,12 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { return withOracle((suggestOracle, editor) => { editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); - return new Promise((resolve, reject) => { - const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { - unexpectedSuggestSub.dispose(); - reject(new Error('Quick suggestions should not have been triggered')); - }); - + // Without an InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - // Wait for the quick suggest delay to pass without triggering - setTimeout(() => { - unexpectedSuggestSub.dispose(); - resolve(); - }, 200); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); }); }); }); @@ -1336,7 +1337,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + test('string shorthand - "offWhenInlineCompletions" allows quick suggest when inline provider returns empty', function () { return runWithFakedTimers({ useFakeTimers: true }, () => { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); @@ -1347,24 +1348,202 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); return withOracle((suggestOracle, editor) => { - // Use string shorthand — applies to all token types + // Use string shorthand - applies to all token types editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); - return new Promise((resolve, reject) => { - const sub = suggestOracle.onDidSuggest(() => { - sub.dispose(); - reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); - }); - + // Without InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + }); + }); + }); + }); +}); - setTimeout(() => { - sub.dispose(); - resolve(); - }, 200); +suite('SuggestModel - offWhenInlineCompletions with InlineCompletionsController', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const completionProvider: CompletionItemProvider = { + _debugDisplayName: 'test', + provideCompletionItems(doc, pos): CompletionList { + const wordUntil = doc.getWordUntilPosition(pos); + return { + incomplete: false, + suggestions: [{ + label: doc.getWordUntilPosition(pos).word, + kind: CompletionItemKind.Property, + insertText: 'foofoo', + range: new Range(pos.lineNumber, wordUntil.startColumn, pos.lineNumber, wordUntil.endColumn) + }] + }; + } + }; + + async function withSuggestModelAndInlineCompletions( + text: string, + inlineProvider: InlineCompletionsProvider, + callback: (suggestModel: SuggestModel, editor: ITestCodeEditor) => Promise, + ): Promise { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const disposableStore = new DisposableStore(); + try { + const languageFeaturesService = new LanguageFeaturesService(); + disposableStore.add(languageFeaturesService.completionProvider.register({ pattern: '**' }, completionProvider)); + disposableStore.add(languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, inlineProvider)); + + const serviceCollection = new ServiceCollection( + [ILanguageFeaturesService, languageFeaturesService], + [ITelemetryService, NullTelemetryService], + [ILogService, new NullLogService()], + [IStorageService, disposableStore.add(new InMemoryStorageService())], + [IKeybindingService, new MockKeybindingService()], + [IEditorWorkerService, new class extends mock() { + override computeWordRanges() { + return Promise.resolve({}); + } + }], + [ISuggestMemoryService, new class extends mock() { + override memorize(): void { } + override select(): number { return 0; } + }], + [IMenuService, new class extends mock() { + override createMenu() { + return new class extends mock() { + override onDidChange = Event.None; + override dispose() { } + }; + } + }], + [ILabelService, new class extends mock() { }], + [IWorkspaceContextService, new class extends mock() { }], + [IEnvironmentService, new class extends mock() { + override isBuilt: boolean = true; + override isExtensionDevelopment: boolean = false; + }], + [IAccessibilitySignalService, new class extends mock() { + override async playSignal() { } + override isSoundEnabled() { return false; } + }], + [IDefaultAccountService, new class extends mock() { + override onDidChangeDefaultAccount = Event.None; + override getDefaultAccount = async () => null; + override setDefaultAccountProvider = () => { }; + }], + ); + + await withAsyncTestCodeEditor(text, { serviceCollection }, async (editor, _editorViewModel, instantiationService) => { + instantiationService.stubInstance(InlineSuggestionsView, { + dispose: () => { } + }); + editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2); + editor.registerAndInstantiateContribution(InlineCompletionsController.ID, InlineCompletionsController); + + editor.hasWidgetFocus = () => true; + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + }); + + const suggestModel = disposableStore.add( + editor.invokeWithinContext(accessor => accessor.get(IInstantiationService).createInstance(SuggestModel, editor)) + ); + + await callback(suggestModel, editor); }); + } finally { + disposableStore.dispose(); + ModifierKeyEmitter.disposeInstance(); + } + }); + } + + test('suppresses quick suggest when inline completions are showing ghost text', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + // Return a completion that extends the current word - must be visible at cursor + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(() => { didSuggest = true; }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, false, 'Quick suggestions should have been suppressed when inline completions are showing'); + }); + }); + + test('allows quick suggest when inline completions resolve with no results', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered after inline completions resolved empty'); + }); + }); + + test('allows quick suggest when inlineSuggest is disabled even with provider', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + editor.updateOptions({ inlineSuggest: { enabled: false } }); + + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered when inlineSuggest is disabled'); }); }); }); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 0824dfbb53337..e12a15076e4f9 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -3,106 +3,106 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './standaloneCodeEditorService.js'; -import './standaloneLayoutService.js'; +import '../../../platform/hover/browser/hoverService.js'; import '../../../platform/undoRedo/common/undoRedoService.js'; +import '../../browser/services/inlineCompletionsService.js'; import '../../common/services/languageFeatureDebounce.js'; -import '../../common/services/semanticTokensStylingService.js'; import '../../common/services/languageFeaturesService.js'; -import '../../../platform/hover/browser/hoverService.js'; -import '../../browser/services/inlineCompletionsService.js'; +import '../../common/services/semanticTokensStylingService.js'; +import './standaloneCodeEditorService.js'; +import './standaloneLayoutService.js'; -import * as strings from '../../../base/common/strings.js'; import * as dom from '../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; +import { onUnexpectedError } from '../../../base/common/errors.js'; import { Emitter, Event, IValueWithChangeEvent, ValueWithChangeEvent } from '../../../base/common/event.js'; -import { ResolvedKeybinding, KeyCodeChord, Keybinding, decodeKeybinding } from '../../../base/common/keybindings.js'; -import { IDisposable, IReference, ImmortalReference, toDisposable, DisposableStore, Disposable, combinedDisposable } from '../../../base/common/lifecycle.js'; +import { KeyCodeChord, Keybinding, ResolvedKeybinding, decodeKeybinding } from '../../../base/common/keybindings.js'; +import { Disposable, DisposableStore, IDisposable, IReference, ImmortalReference, combinedDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../base/common/map.js'; import { OS, isLinux, isMacintosh } from '../../../base/common/platform.js'; +import { basename } from '../../../base/common/resources.js'; import Severity from '../../../base/common/severity.js'; +import * as strings from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; -import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../browser/services/renameSymbolTrackerService.js'; -import { IBulkEditOptions, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../browser/services/bulkEditService.js'; -import { isDiffEditorConfigurationKey, isEditorConfigurationKey } from '../../common/config/editorConfigurationSchema.js'; -import { EditOperation, ISingleEditOperation } from '../../common/core/editOperation.js'; -import { IPosition, Position as Pos } from '../../common/core/position.js'; -import { Range } from '../../common/core/range.js'; -import { ITextModel, ITextSnapshot } from '../../common/model.js'; -import { IModelService } from '../../common/services/model.js'; -import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../common/services/resolverService.js'; -import { ITextResourceConfigurationService, ITextResourcePropertiesService, ITextResourceConfigurationChangeEvent } from '../../common/services/textResourceConfiguration.js'; -import { CommandsRegistry, ICommandEvent, ICommandHandler, ICommandService } from '../../../platform/commands/common/commands.js'; -import { IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationService, IConfigurationModel, IConfigurationValue, ConfigurationTarget } from '../../../platform/configuration/common/configuration.js'; -import { Configuration, ConfigurationModel, ConfigurationChangeEvent } from '../../../platform/configuration/common/configurationModels.js'; -import { IContextKeyService, ContextKeyExpression } from '../../../platform/contextkey/common/contextkey.js'; -import { IConfirmation, IConfirmationResult, IDialogService, IInputResult, IPrompt, IPromptResult, IPromptWithCustomCancel, IPromptResultWithCancel, IPromptWithDefaultCancel, IPromptBaseButton } from '../../../platform/dialogs/common/dialogs.js'; -import { createDecorator, IInstantiationService, ServiceIdentifier } from '../../../platform/instantiation/common/instantiation.js'; -import { AbstractKeybindingService } from '../../../platform/keybinding/common/abstractKeybindingService.js'; -import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../platform/keybinding/common/keybinding.js'; -import { KeybindingResolver } from '../../../platform/keybinding/common/keybindingResolver.js'; -import { IKeybindingItem, KeybindingsRegistry } from '../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ResolvedKeybindingItem } from '../../../platform/keybinding/common/resolvedKeybindingItem.js'; -import { USLayoutResolvedKeybinding } from '../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; -import { ILabelService, ResourceLabelFormatter, IFormatterChangeEvent, Verbosity } from '../../../platform/label/common/label.js'; -import { INotification, INotificationHandle, INotificationService, IPromptChoice, IPromptOptions, NoOpNotification, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter, IStatusHandle } from '../../../platform/notification/common/notification.js'; -import { IProgressRunner, IEditorProgressService, IProgressService, IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressNotificationOptions, IProgressOptions, IProgressStep, IProgressWindowOptions } from '../../../platform/progress/common/progress.js'; -import { ITelemetryService, TelemetryLevel } from '../../../platform/telemetry/common/telemetry.js'; -import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier, IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, WorkbenchState, WorkspaceFolder, STANDALONE_EDITOR_WORKSPACE_ID } from '../../../platform/workspace/common/workspace.js'; -import { ILayoutService } from '../../../platform/layout/browser/layoutService.js'; -import { StandaloneServicesNLS } from '../../common/standaloneStrings.js'; -import { basename } from '../../../base/common/resources.js'; -import { ICodeEditorService } from '../../browser/services/codeEditorService.js'; -import { ConsoleLogger, ILoggerService, ILogService, NullLoggerService } from '../../../platform/log/common/log.js'; -import { IWorkspaceTrustManagementService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo } from '../../../platform/workspace/common/workspaceTrust.js'; -import { EditorOption } from '../../common/config/editorOptions.js'; -import { ICodeEditor, IDiffEditor } from '../../browser/editorBrowser.js'; -import { IContextMenuService, IContextViewDelegate, IContextViewService, IOpenContextView } from '../../../platform/contextview/browser/contextView.js'; -import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; -import { LanguageService } from '../../common/services/languageService.js'; -import { ContextMenuService } from '../../../platform/contextview/browser/contextMenuService.js'; -import { getSingletonServiceDescriptors, InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; -import { OpenerService } from '../../browser/services/openerService.js'; -import { ILanguageService } from '../../common/languages/language.js'; -import { MarkerDecorationsService } from '../../common/services/markerDecorationsService.js'; -import { IMarkerDecorationsService } from '../../common/services/markerDecorations.js'; -import { ModelService } from '../../common/services/modelService.js'; -import { StandaloneQuickInputService } from './quickInput/standaloneQuickInputService.js'; -import { StandaloneThemeService } from './standaloneThemeService.js'; -import { IStandaloneThemeService } from '../common/standaloneTheme.js'; import { AccessibilityService } from '../../../platform/accessibility/browser/accessibilityService.js'; import { IAccessibilityService } from '../../../platform/accessibility/common/accessibility.js'; +import { AccessibilityModality, AccessibilitySignal, IAccessibilitySignalService, Sound } from '../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IMenuService } from '../../../platform/actions/common/actions.js'; import { MenuService } from '../../../platform/actions/common/menuService.js'; import { BrowserClipboardService } from '../../../platform/clipboard/browser/clipboardService.js'; import { IClipboardService } from '../../../platform/clipboard/common/clipboardService.js'; +import { CommandsRegistry, ICommandEvent, ICommandHandler, ICommandService } from '../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationData, IConfigurationModel, IConfigurationOverrides, IConfigurationService, IConfigurationValue } from '../../../platform/configuration/common/configuration.js'; +import { Configuration, ConfigurationChangeEvent, ConfigurationModel } from '../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration } from '../../../platform/configuration/common/configurations.js'; import { ContextKeyService } from '../../../platform/contextkey/browser/contextKeyService.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { ContextMenuService } from '../../../platform/contextview/browser/contextMenuService.js'; +import { IContextMenuService, IContextViewDelegate, IContextViewService, IOpenContextView } from '../../../platform/contextview/browser/contextView.js'; +import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; +import { IDataChannelService, NullDataChannelService } from '../../../platform/dataChannel/common/dataChannel.js'; +import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; +import { IConfirmation, IConfirmationResult, IDialogService, IInputResult, IPrompt, IPromptBaseButton, IPromptResult, IPromptResultWithCancel, IPromptWithCustomCancel, IPromptWithDefaultCancel } from '../../../platform/dialogs/common/dialogs.js'; +import { ExtensionKind, IEnvironmentService, IExtensionHostDebugParams } from '../../../platform/environment/common/environment.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; +import { InstantiationType, getSingletonServiceDescriptors, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServiceIdentifier, createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { InstantiationService } from '../../../platform/instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; +import { AbstractKeybindingService } from '../../../platform/keybinding/common/abstractKeybindingService.js'; +import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../platform/keybinding/common/keybinding.js'; +import { KeybindingResolver } from '../../../platform/keybinding/common/keybindingResolver.js'; +import { IKeybindingItem, KeybindingsRegistry } from '../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ResolvedKeybindingItem } from '../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { USLayoutResolvedKeybinding } from '../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; +import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, Verbosity } from '../../../platform/label/common/label.js'; +import { ILayoutService } from '../../../platform/layout/browser/layoutService.js'; import { IListService, ListService } from '../../../platform/list/browser/listService.js'; +import { ConsoleLogger, ILogService, ILoggerService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { LogService } from '../../../platform/log/common/logService.js'; import { IMarkerService } from '../../../platform/markers/common/markers.js'; import { MarkerService } from '../../../platform/markers/common/markerService.js'; +import { INotification, INotificationHandle, INotificationService, INotificationSource, INotificationSourceFilter, IPromptChoice, IPromptOptions, IStatusHandle, IStatusMessageOptions, NoOpNotification, NotificationsFilter } from '../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; +import { IEditorProgressService, IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressNotificationOptions, IProgressOptions, IProgressRunner, IProgressService, IProgressStep, IProgressWindowOptions } from '../../../platform/progress/common/progress.js'; import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; import { IStorageService, InMemoryStorageService } from '../../../platform/storage/common/storage.js'; -import { DefaultConfiguration } from '../../../platform/configuration/common/configurations.js'; -import { WorkspaceEdit } from '../../common/languages.js'; -import { AccessibilitySignal, AccessibilityModality, IAccessibilitySignalService, Sound } from '../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { LogService } from '../../../platform/log/common/logService.js'; +import { ITelemetryService, TelemetryLevel } from '../../../platform/telemetry/common/telemetry.js'; +import { IUserInteractionService } from '../../../platform/userInteraction/browser/userInteractionService.js'; +import { UserInteractionService } from '../../../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; +import { ISingleFolderWorkspaceIdentifier, IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, IWorkspaceIdentifier, STANDALONE_EDITOR_WORKSPACE_ID, WorkbenchState, WorkspaceFolder } from '../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo } from '../../../platform/workspace/common/workspaceTrust.js'; +import { ICodeEditor, IDiffEditor } from '../../browser/editorBrowser.js'; +import { IBulkEditOptions, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../browser/services/bulkEditService.js'; +import { ICodeEditorService } from '../../browser/services/codeEditorService.js'; +import { OpenerService } from '../../browser/services/openerService.js'; +import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../browser/services/renameSymbolTrackerService.js'; +import { isDiffEditorConfigurationKey, isEditorConfigurationKey } from '../../common/config/editorConfigurationSchema.js'; +import { EditorOption } from '../../common/config/editorOptions.js'; +import { EditOperation, ISingleEditOperation } from '../../common/core/editOperation.js'; +import { IPosition, Position as Pos } from '../../common/core/position.js'; +import { Range } from '../../common/core/range.js'; import { getEditorFeatures } from '../../common/editorFeatures.js'; -import { onUnexpectedError } from '../../../base/common/errors.js'; -import { ExtensionKind, IEnvironmentService, IExtensionHostDebugParams } from '../../../platform/environment/common/environment.js'; -import { mainWindow } from '../../../base/browser/window.js'; -import { ResourceMap } from '../../../base/common/map.js'; +import { WorkspaceEdit } from '../../common/languages.js'; +import { ILanguageService } from '../../common/languages/language.js'; +import { ITextModel, ITextSnapshot } from '../../common/model.js'; +import { LanguageService } from '../../common/services/languageService.js'; +import { IMarkerDecorationsService } from '../../common/services/markerDecorations.js'; +import { MarkerDecorationsService } from '../../common/services/markerDecorationsService.js'; +import { IModelService } from '../../common/services/model.js'; +import { ModelService } from '../../common/services/modelService.js'; +import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../common/services/resolverService.js'; +import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService, ITextResourcePropertiesService } from '../../common/services/textResourceConfiguration.js'; import { ITreeSitterLibraryService } from '../../common/services/treeSitter/treeSitterLibraryService.js'; -import { StandaloneTreeSitterLibraryService } from './standaloneTreeSitterLibraryService.js'; -import { IDataChannelService, NullDataChannelService } from '../../../platform/dataChannel/common/dataChannel.js'; -import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; +import { StandaloneServicesNLS } from '../../common/standaloneStrings.js'; +import { IStandaloneThemeService } from '../common/standaloneTheme.js'; +import { StandaloneQuickInputService } from './quickInput/standaloneQuickInputService.js'; import { StandaloneWebWorkerService } from './services/standaloneWebWorkerService.js'; -import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; -import { IUserInteractionService } from '../../../platform/userInteraction/browser/userInteractionService.js'; -import { UserInteractionService } from '../../../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import { StandaloneThemeService } from './standaloneThemeService.js'; +import { StandaloneTreeSitterLibraryService } from './standaloneTreeSitterLibraryService.js'; class SimpleModel implements IResolvedTextEditorModel { @@ -852,6 +852,10 @@ class StandaloneWorkspaceContextService implements IWorkspaceContextService { return WorkbenchState.EMPTY; } + public hasWorkspaceData(): boolean { + return this.getWorkbenchState() !== WorkbenchState.EMPTY; + } + public getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { return resource && resource.scheme === StandaloneWorkspaceContextService.SCHEME ? this.workspace.folders[0] : null; } @@ -1119,6 +1123,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly onDidChangeDefaultAccount: Event = Event.None; readonly onDidChangePolicyData: Event = Event.None; readonly policyData: IPolicyData | null = null; + readonly copilotTokenInfo = null; + readonly onDidChangeCopilotTokenInfo: Event = Event.None; async getDefaultAccount(): Promise { return null; diff --git a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts index 511842715693f..f0f79d731d105 100644 --- a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts @@ -506,6 +506,114 @@ suite('Cursor move by blankline test', () => { }); }); +// Tests for 'foldedLine' unit: moves by model lines but treats each fold as a single step. +// This is the semantics required by vim's j/k: move through visible lines, skip hidden ones. + +suite('Cursor move command - foldedLine unit', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function executeFoldTest(callback: (editor: ITestCodeEditor, viewModel: ViewModel) => void): void { + withTestCodeEditor([ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + ].join('\n'), {}, (editor, viewModel) => { + callback(editor, viewModel); + }); + } + + test('move down by foldedLine skips a fold below the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 4 is hidden (folded under line 3 as header) + viewModel.setHiddenAreas([new Range(4, 1, 4, 1)]); + moveTo(viewModel, 2, 1); + // j from line 2 → line 3 (visible fold header) + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 3, 1); + // j from line 3 (fold header) → line 4 is hidden, lands on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine skips a fold above the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden (folded under line 2 as header) + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 4, 1); + // k from line 4: line 3 is hidden, lands on line 2 (fold header) + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 2, 1); + // k from line 2 → line 1 + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count treats each fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 1, 1); + // 3j from line 1: step1→2, step2→3(hidden)→4, step3→5 + moveDownByFoldedLine(viewModel, 3); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine skips a multi-line fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden (folded under line 1 as header) + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // j from line 1: lines 2-4 are all hidden, lands directly on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine at last line stays at last line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 5, 1); + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine at first line stays at first line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 1, 1); + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count clamps to last visible line after fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // 2j should land on line 5 and clamp there. + moveDownByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine with count clamps to first visible line before fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 5, 1); + // 2k should land on line 1 and clamp there. + moveUpByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 1, 1); + }); + }); +}); + // Move command function move(viewModel: ViewModel, args: any) { @@ -564,6 +672,14 @@ function moveDownByModelLine(viewModel: ViewModel, noOfLines: number = 1, select move(viewModel, { to: CursorMove.RawDirection.Down, value: noOfLines, select: select }); } +function moveDownByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Down, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + +function moveUpByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Up, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + function moveToTop(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { move(viewModel, { to: CursorMove.RawDirection.ViewPortTop, value: noOfLines, select: select }); } diff --git a/src/vs/editor/test/common/model/model.test.ts b/src/vs/editor/test/common/model/model.test.ts index 72140c2636000..953e761c4085d 100644 --- a/src/vs/editor/test/common/model/model.test.ts +++ b/src/vs/editor/test/common/model/model.test.ts @@ -130,7 +130,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, 'foo My First Line', null) + new ModelRawLineChanged(1, 1) ], 2, false, @@ -144,8 +144,8 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, 'My new line', null), - new ModelRawLinesInserted(2, 2, 1, ['No longer First Line'], [null]), + new ModelRawLineChanged(1, 1), + new ModelRawLinesInserted(2, 2, 1), ], 2, false, @@ -216,7 +216,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, 'y First Line', null), + new ModelRawLineChanged(1, 1), ], 2, false, @@ -230,7 +230,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, '', null), + new ModelRawLineChanged(1, 1), ], 2, false, @@ -244,8 +244,8 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, 'My Second Line', null), - new ModelRawLinesDeleted(2, 2), + new ModelRawLineChanged(1, 1), + new ModelRawLinesDeleted(2, 2, 1), ], 2, false, @@ -259,8 +259,8 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 1, 'My Third Line', null), - new ModelRawLinesDeleted(2, 3), + new ModelRawLineChanged(1, 1), + new ModelRawLinesDeleted(2, 3, 1), ], 2, false, diff --git a/src/vs/editor/test/common/model/modelInjectedText.test.ts b/src/vs/editor/test/common/model/modelInjectedText.test.ts index d01509f642110..f67efe5677f91 100644 --- a/src/vs/editor/test/common/model/modelInjectedText.test.ts +++ b/src/vs/editor/test/common/model/modelInjectedText.test.ts @@ -8,7 +8,7 @@ import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { EditOperation } from '../../../common/core/editOperation.js'; import { Range } from '../../../common/core/range.js'; -import { InternalModelContentChangeEvent, LineInjectedText, ModelInjectedTextChangedEvent, ModelRawChange, RawContentChangedType } from '../../../common/textModelEvents.js'; +import { InternalModelContentChangeEvent, ModelInjectedTextChangedEvent, ModelRawChange, RawContentChangedType } from '../../../common/textModelEvents.js'; import { IViewModel } from '../../../common/viewModel.js'; import { createTextModel } from '../testTextModel.js'; @@ -43,8 +43,8 @@ suite('Editor Model - Injected Text Events', () => { assert.deepStrictEqual(recordedChanges.splice(0), [ { kind: 'lineChanged', - line: '[injected1]First Line', lineNumber: 1, + lineNumberPostEdit: 1, } ]); @@ -67,13 +67,13 @@ suite('Editor Model - Injected Text Events', () => { assert.deepStrictEqual(recordedChanges.splice(0), [ { kind: 'lineChanged', - line: 'First Line', lineNumber: 1, + lineNumberPostEdit: 1, }, { kind: 'lineChanged', - line: '[injected1]S[injected2]econd Line', lineNumber: 2, + lineNumberPostEdit: 2, } ]); @@ -82,8 +82,8 @@ suite('Editor Model - Injected Text Events', () => { assert.deepStrictEqual(recordedChanges.splice(0), [ { kind: 'lineChanged', - line: '[injected1]SHello[injected2]econd Line', lineNumber: 2, + lineNumberPostEdit: 2, } ]); @@ -100,17 +100,13 @@ suite('Editor Model - Injected Text Events', () => { assert.deepStrictEqual(recordedChanges.splice(0), [ { kind: 'lineChanged', - line: '[injected1]S', lineNumber: 2, + lineNumberPostEdit: 2, }, { - fromLineNumber: 3, kind: 'linesInserted', - lines: [ - '', - '', - 'Hello[injected2]econd Line', - ] + fromLineNumber: 3, + count: 3, } ]); @@ -119,36 +115,24 @@ suite('Editor Model - Injected Text Events', () => { thisModel.pushEditOperations(null, [EditOperation.replace(new Range(3, 1, 5, 1), '\n\n\n\n\n\n\n\n\n\n\n\n\n')], null); assert.deepStrictEqual(recordedChanges.splice(0), [ { - 'kind': 'lineChanged', - 'line': '', - 'lineNumber': 5, + kind: 'lineChanged', + lineNumber: 5, + lineNumberPostEdit: 5, }, { - 'kind': 'lineChanged', - 'line': '', - 'lineNumber': 4, + kind: 'lineChanged', + lineNumber: 4, + lineNumberPostEdit: 4, }, { - 'kind': 'lineChanged', - 'line': '', - 'lineNumber': 3, + kind: 'lineChanged', + lineNumber: 3, + lineNumberPostEdit: 3, }, { - 'fromLineNumber': 6, - 'kind': 'linesInserted', - 'lines': [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - 'Hello[injected2]econd Line', - ] + kind: 'linesInserted', + fromLineNumber: 6, + count: 11, } ]); @@ -157,8 +141,8 @@ suite('Editor Model - Injected Text Events', () => { assert.deepStrictEqual(recordedChanges.splice(0), [ { kind: 'lineChanged', - line: '[injected1]SHello[injected2]econd Line', lineNumber: 2, + lineNumberPostEdit: 2, }, { kind: 'linesDeleted', @@ -171,20 +155,16 @@ suite('Editor Model - Injected Text Events', () => { function mapChange(change: ModelRawChange): unknown { if (change.changeType === RawContentChangedType.LineChanged) { - (change.injectedText || []).every(e => { - assert.deepStrictEqual(e.lineNumber, change.lineNumber); - }); - return { kind: 'lineChanged', - line: getDetail(change.detail, change.injectedText), lineNumber: change.lineNumber, + lineNumberPostEdit: change.lineNumberPostEdit, }; } else if (change.changeType === RawContentChangedType.LinesInserted) { return { kind: 'linesInserted', - lines: change.detail.map((e, idx) => getDetail(e, change.injectedTexts[idx])), - fromLineNumber: change.fromLineNumber + fromLineNumber: change.fromLineNumber, + count: change.count, }; } else if (change.changeType === RawContentChangedType.LinesDeleted) { return { @@ -201,7 +181,3 @@ function mapChange(change: ModelRawChange): unknown { } return { kind: 'unknown' }; } - -function getDetail(line: string, injectedTexts: LineInjectedText[] | null): string { - return LineInjectedText.applyInjectedText(line, (injectedTexts || []).map(t => t.withText(`[${t.options.content}]`))); -} diff --git a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts index 48b50f306162a..695650dd8a355 100644 --- a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts @@ -182,7 +182,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); - manager.onLinesInserted(3, 4, []); // Insert 2 lines at line 3 + manager.onLinesInserted(3, 4); // Insert 2 lines at line 3 assert.strictEqual(manager.heightForLineNumber(5), 10); assert.strictEqual(manager.heightForLineNumber(6), 10); @@ -195,7 +195,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); - manager.onLinesInserted(6, 7, []); // Insert 2 lines at line 6 + manager.onLinesInserted(6, 7); // Insert 2 lines at line 6 assert.strictEqual(manager.heightForLineNumber(5), 20); assert.strictEqual(manager.heightForLineNumber(6), 20); @@ -267,9 +267,8 @@ suite('Editor ViewLayout - LineHeightsManager', () => { assert.strictEqual(manager.heightForLineNumber(2), 10); // Insert line 2 to line 2, with the same decoration ID 'decA' covering line 2 - manager.onLinesInserted(2, 2, [ - new CustomLineHeightData('decA', 2, 2, 30) - ]); + manager.onLinesInserted(2, 2); + manager.insertOrChangeCustomLineHeight('decA', 2, 2, 30); // After insertion, the decoration 'decA' now covers line 2 // Since insertOrChangeCustomLineHeight removes the old decoration first, @@ -349,7 +348,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { // Caller A removes its decoration before any flush occurs. manager.removeCustomLineHeight('decA'); // Caller B triggers a structural change that causes queue flush in the middle of commit. - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // decA must stay removed. If queued inserts are not canceled on remove, decA incorrectly survives. assert.strictEqual(manager.heightForLineNumber(4), 10); @@ -381,7 +380,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { manager.insertOrChangeCustomLineHeight('dec1', 2, 2, 20); manager.insertOrChangeCustomLineHeight('dec2', 5, 5, 30); // Step 3: insert 2 lines at line 3 (shifts dec2 from line 5 → 7) - manager.onLinesInserted(3, 4, []); + manager.onLinesInserted(3, 4); // Step 4: delete line 1 (shifts dec1 from line 2 → 1, dec2 from line 7 → 6) manager.onLinesDeleted(1, 1); // Step 5-6: remove the two decorations @@ -402,7 +401,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 1 line at line 1 → dec1 shifts from 3 → 4 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); manager.removeCustomLineHeight('dec1'); // Read — no explicit commit assert.strictEqual(manager.heightForLineNumber(3), 10); @@ -442,7 +441,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 2 lines at line 1 → dec1 moves from 3 → 5 - manager.onLinesInserted(1, 2, []); + manager.onLinesInserted(1, 2); // Delete line 1 → dec1 moves from 5 → 4 manager.onLinesDeleted(1, 1); // Read @@ -455,9 +454,9 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { const manager = new LineHeightsManager(10, []); manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 1 line at line 1 → dec1 at 3 → 4 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // Insert 1 line at line 1 → dec1 at 4 → 5 - manager.onLinesInserted(1, 1, []); + manager.onLinesInserted(1, 1); // Read assert.strictEqual(manager.heightForLineNumber(5), 20); assert.strictEqual(manager.heightForLineNumber(3), 10); @@ -492,7 +491,7 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { // Insert a decoration at line 3 (pending, not committed) manager.insertOrChangeCustomLineHeight('dec1', 3, 3, 20); // Insert 2 lines before it at line 1 → should shift dec1 from 3 → 5 - manager.onLinesInserted(1, 2, []); + manager.onLinesInserted(1, 2); // Read assert.strictEqual(manager.heightForLineNumber(3), 10); assert.strictEqual(manager.heightForLineNumber(5), 20); @@ -524,4 +523,13 @@ suite('Editor ViewLayout - LineHeightsManager (auto-commit on read)', () => { assert.strictEqual(manager.heightForLineNumber(6), 30); assert.strictEqual(manager.getAccumulatedLineHeightsIncludingLineNumber(6), 110); }); + + test('deleting line 2 with lineHeightsRemoved re-adding at line 1 moves special line to line 1', () => { + const manager = new LineHeightsManager(10, []); + manager.insertOrChangeCustomLineHeight('dec1', 2, 2, 20); + assert.strictEqual(manager.heightForLineNumber(2), 20); + manager.onLinesDeleted(2, 2); + manager.insertOrChangeCustomLineHeight('dec1', 1, 1, 20); + assert.strictEqual(manager.heightForLineNumber(1), 20); + }); }); diff --git a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts index ceb624ac2740e..7bf20a78d8457 100644 --- a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts +++ b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts @@ -208,7 +208,7 @@ suite('Editor ViewLayout - LinesLayout', () => { // Insert two lines at the beginning // 10 lines // whitespace: - a(6,10) - linesLayout.onLinesInserted(1, 2, []); + linesLayout.onLinesInserted(1, 2); assert.strictEqual(linesLayout.getLinesTotalHeight(), 20); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(1), 0); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(2), 1); @@ -909,7 +909,7 @@ suite('Editor ViewLayout - LinesLayout', () => { assert.strictEqual(linesLayout.getWhitespaceAccumulatedHeightBeforeLineNumber(5), 50); // Insert a line before line 1 - linesLayout.onLinesInserted(1, 1, []); + linesLayout.onLinesInserted(1, 1); // whitespaces: d(3, 30), c(4, 20) assert.strictEqual(linesLayout.getWhitespacesCount(), 2); assert.strictEqual(linesLayout.getAfterLineNumberForWhitespaceIndex(0), 3); diff --git a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts index bed861e44a1bf..70d687d30e442 100644 --- a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { EditorOptions, WrappingIndent } from '../../../common/config/editorOptions.js'; import { FontInfo } from '../../../common/config/fontInfo.js'; -import { ILineBreaksComputerFactory, ModelLineProjectionData } from '../../../common/modelLineProjectionData.js'; +import { ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../../common/modelLineProjectionData.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; function parseAnnotatedText(annotatedText: string): { text: string; indices: number[] } { @@ -63,9 +63,17 @@ function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, wsmiddotWidth: 7, maxDigitWidth: 7 }, false); - const lineBreaksComputer = factory.createLineBreaksComputer(fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds); + const context: ILineBreaksComputerContext = { + getLineContent(lineNumber: number) { + return text; + }, + getLineInjectedText(lineNumber) { + return null; + } + }; + const lineBreaksComputer = factory.createLineBreaksComputer(context, fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds); const previousLineBreakDataClone = previousLineBreakData ? new ModelLineProjectionData(null, null, previousLineBreakData.breakOffsets.slice(0), previousLineBreakData.breakOffsetsVisibleColumn.slice(0), previousLineBreakData.wrappedTextIndentLength) : null; - lineBreaksComputer.addRequest(text, null, previousLineBreakDataClone); + lineBreaksComputer.addRequest(1, previousLineBreakDataClone); return lineBreaksComputer.finalize()[0]; } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index ce6731adeb770..5b0047e74a3f2 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4255,6 +4255,10 @@ declare namespace monaco.editor { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; } export type GoToLocationValues = 'peek' | 'gotoAndPeek' | 'goto'; @@ -4307,6 +4311,11 @@ declare namespace monaco.editor { * Defaults to false. */ above?: boolean; + /** + * Should long line warning hovers be shown (tokenization skipped, rendering paused)? + * Defaults to true. + */ + showLongLineWarning?: boolean; } /** @@ -5254,7 +5263,7 @@ declare namespace monaco.editor { export const EditorOptions: { acceptSuggestionOnCommitCharacter: IEditorOption; acceptSuggestionOnEnter: IEditorOption; - accessibilitySupport: IEditorOption; + accessibilitySupport: IEditorOption; accessibilityPageSize: IEditorOption; allowOverflow: IEditorOption; allowVariableLineHeights: IEditorOption; @@ -5317,7 +5326,7 @@ declare namespace monaco.editor { foldingMaximumRegions: IEditorOption; unfoldOnClickAfterEndOfLine: IEditorOption; fontFamily: IEditorOption; - fontInfo: IEditorOption; + fontInfo: IEditorOption; fontLigatures2: IEditorOption; fontSize: IEditorOption; fontWeight: IEditorOption; @@ -5357,7 +5366,7 @@ declare namespace monaco.editor { pasteAs: IEditorOption>>; parameterHints: IEditorOption>>; peekWidgetDefaultFocus: IEditorOption; - placeholder: IEditorOption; + placeholder: IEditorOption; definitionLinkOpensInPeek: IEditorOption; quickSuggestions: IEditorOption; quickSuggestionsDelay: IEditorOption; diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 6d2cfda35684d..fc09cb44b394e 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -10,13 +10,14 @@ import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/con import { KeybindingLabel } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IListEvent, IListMouseEvent, IListRenderer, IListVirtualDelegate } from '../../../base/browser/ui/list/list.js'; import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/listWidget.js'; -import { IAction } from '../../../base/common/actions.js'; +import { IAction, SubmenuAction, toAction } from '../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; +import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { AnchorPosition } from '../../../base/common/layout.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { URI } from '../../../base/common/uri.js'; @@ -28,6 +29,7 @@ import { IOpenerService } from '../../opener/common/opener.js'; import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IHoverService } from '../../hover/browser/hover.js'; import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { IHoverPositionOptions, IHoverWidget } from '../../../base/browser/ui/hover/hover.js'; @@ -65,6 +67,12 @@ export interface IActionListItem { * Optional hover configuration shown when focusing/hovering over the item. */ readonly hover?: IActionListItemHover; + /** + * Optional actions shown in a nested submenu panel, triggered by a chevron + * indicator on the right side of the item. When set, hovering or clicking + * the chevron opens an inline submenu with these actions. + */ + readonly submenuActions?: IAction[]; readonly keybinding?: ResolvedKeybinding; canPreview?: boolean | undefined; readonly hideIcon?: boolean; @@ -96,6 +104,11 @@ export interface IActionListItem { * When true, this item is always shown when filtering produces no other results. */ readonly showAlways?: boolean; + /** + * Optional callback invoked when the item is removed via the built-in remove button. + * When set, a close button is automatically added to the item toolbar. + */ + readonly onRemove?: () => void; } interface IActionMenuTemplateData { @@ -106,6 +119,7 @@ interface IActionMenuTemplateData { readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; + readonly submenuIndicator: HTMLElement; readonly elementDisposables: DisposableStore; previousClassName?: string; } @@ -176,6 +190,9 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, + private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, + private _hasAnySubmenuActions: boolean, + private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -205,9 +222,13 @@ class ActionItemRenderer implements IListRenderer, IAction toolbar.className = 'action-list-item-toolbar'; container.append(toolbar); + const submenuIndicator = document.createElement('div'); + submenuIndicator.className = 'action-list-submenu-indicator'; + container.append(submenuIndicator); + const elementDisposables = new DisposableStore(); - return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, badge, description, keybinding, toolbar, submenuIndicator, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -263,7 +284,12 @@ class ActionItemRenderer implements IListRenderer, IAction } else { const rendered = renderMarkdown(element.description, { actionHandler: (content: string) => { - this._openerService.open(URI.parse(content), { allowCommands: true }); + const uri = URI.parse(content); + if (this._linkHandler) { + this._linkHandler(uri, element); + } else { + void this._openerService.open(uri, { allowCommands: true }); + } } }); data.elementDisposables.add(rendered); @@ -297,11 +323,37 @@ class ActionItemRenderer implements IListRenderer, IAction // Clear and render toolbar actions dom.clearNode(data.toolbar); - data.container.classList.toggle('has-toolbar', !!element.toolbarActions?.length); - if (element.toolbarActions?.length) { + const toolbarActions = [...(element.toolbarActions ?? [])]; + if (element.onRemove) { + toolbarActions.push(toAction({ + id: 'actionList.remove', + label: localize('actionList.remove', "Remove"), + class: ThemeIcon.asClassName(Codicon.close), + run: () => { + element.onRemove!(); + this._onRemoveItem?.(element); + }, + })); + } + data.container.classList.toggle('has-toolbar', toolbarActions.length > 0); + if (toolbarActions.length > 0) { const actionBar = new ActionBar(data.toolbar); data.elementDisposables.add(actionBar); - actionBar.push(element.toolbarActions, { icon: true, label: false }); + actionBar.push(toolbarActions, { icon: true, label: false }); + } + + // Show submenu indicator for items with submenu actions + const hasSubmenu = !!element.submenuActions?.length; + if (hasSubmenu) { + data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); + data.submenuIndicator.style.display = ''; + } else if (this._hasAnySubmenuActions) { + // Reserve space for alignment when other items have submenus + data.submenuIndicator.className = 'action-list-submenu-indicator'; + data.submenuIndicator.style.display = ''; + } else { + // No items have submenu actions — hide completely + data.submenuIndicator.style.display = 'none'; } } @@ -341,6 +393,11 @@ export interface IActionListOptions { */ readonly filterPlaceholder?: string; + /** + * Optional actions shown in the filter row, to the right of the input. + */ + readonly filterActions?: readonly IAction[]; + /** * Section IDs that should be collapsed by default. */ @@ -351,6 +408,23 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Optional handler for markdown links activated in item descriptions or hovers. + * When unset, links open via the opener service with command links allowed. + */ + readonly linkHandler?: (uri: URI, item: IActionListItem) => void; + + /** + * Optional callback fired when a section's collapsed state changes. + */ + readonly onDidToggleSection?: (section: string, collapsed: boolean) => void; + + /** + * When true, descriptions are rendered as subtext below the title + * instead of inline to the right. + */ + readonly descriptionBelow?: boolean; + /** @@ -359,60 +433,84 @@ export interface IActionListOptions { readonly focusFilterOnOpen?: boolean; } -export class ActionList extends Disposable { +/** + * A standalone action list widget that handles core list rendering, filtering, + * hover, submenu, and section management without depending on IContextViewService + * or anchor-based positioning. Suitable for embedding directly in any container. + */ +export class ActionListWidget extends Disposable { public readonly domNode: HTMLElement; private readonly _list: List>; - private readonly _actionLineHeight = 24; - private readonly _headerLineHeight = 24; - private readonly _separatorLineHeight = 8; + protected readonly _actionLineHeight: number; + protected readonly _headerLineHeight = 24; + protected readonly _separatorLineHeight = 8; - private readonly _allMenuItems: readonly IActionListItem[]; + protected _allMenuItems: IActionListItem[]; private readonly cts = this._register(new CancellationTokenSource()); private _hover = this._register(new MutableDisposable()); + private readonly _submenuDisposables = this._register(new DisposableStore()); + private readonly _submenuContainer: HTMLElement; + private _submenuHideTimeout: ReturnType | undefined; + private _submenuShowTimeout: ReturnType | undefined; + private _currentSubmenuWidget: ActionListWidget | undefined; + private _currentSubmenuElement: IActionListItem | undefined; + private readonly _collapsedSections = new Set(); private _filterText = ''; private _suppressHover = false; private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; - private _lastMinWidth = 0; - private _cachedMaxWidth: number | undefined; - private _hasLaidOut = false; - private _showAbove: boolean | undefined; + + private readonly _onDidRequestLayout = this._register(new Emitter()); /** - * Returns the resolved anchor position after the first layout. - * Used by the context view delegate to lock the dropdown direction. + * Fired when the widget's visible item set changes and the parent should + * re-layout (e.g. after filtering or collapsing a section). */ - get anchorPosition(): AnchorPosition | undefined { - if (this._showAbove === undefined) { - return undefined; - } - return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW; - } + readonly onDidRequestLayout = this._onDidRequestLayout.event; constructor( user: string, preview: boolean, items: readonly IActionListItem[], - private readonly _delegate: IActionListDelegate, + protected readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, - private readonly _options: IActionListOptions | undefined, - private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor, - @IContextViewService private readonly _contextViewService: IContextViewService, + protected readonly _options: IActionListOptions | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, - @ILayoutService private readonly _layoutService: ILayoutService, @IHoverService private readonly _hoverService: IHoverService, @IOpenerService private readonly _openerService: IOpenerService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); + if (this._options?.descriptionBelow) { + this.domNode.classList.add('description-below'); + } + this._actionLineHeight = this._options?.descriptionBelow ? 48 : 24; + + // Create submenu container appended to domNode + this._submenuContainer = document.createElement('div'); + this._submenuContainer.className = 'action-list-submenu-panel action-widget'; + this._submenuContainer.style.display = 'none'; + this.domNode.append(this._submenuContainer); + + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseenter', () => { + this._cancelSubmenuHide(); + })); + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseleave', () => { + this._scheduleSubmenuHide(); + })); + this._register(toDisposable(() => { + this._cancelSubmenuHide(); + this._cancelSubmenuShow(); + })); // Initialize collapsed sections if (this._options?.collapsedByDefault) { @@ -436,8 +534,10 @@ export class ActionList extends Disposable { }; + const hasAnySubmenuActions = items.some(item => !!item.submenuActions?.length); + this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer>(preview, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -482,19 +582,27 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeFocus(() => this.onFocus())); this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); - this._allMenuItems = items; + this._allMenuItems = [...items]; // Create filter input if (this._options?.showFilter) { this._filterContainer = document.createElement('div'); this._filterContainer.className = 'action-list-filter'; + const filterRow = dom.append(this._filterContainer, dom.$('.action-list-filter-row')); this._filterInput = document.createElement('input'); this._filterInput.type = 'text'; this._filterInput.className = 'action-list-filter-input'; this._filterInput.placeholder = this._options?.filterPlaceholder ?? localize('actionList.filter.placeholder', "Search..."); this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); - this._filterContainer.appendChild(this._filterInput); + filterRow.appendChild(this._filterInput); + + const filterActions = this._options?.filterActions ?? []; + if (filterActions.length > 0) { + const filterActionsContainer = dom.append(filterRow, dom.$('.action-list-filter-actions')); + const filterActionBar = this._register(new ActionBar(filterActionsContainer)); + filterActionBar.push(filterActions, { icon: true, label: false }); + } this._register(dom.addDisposableListener(this._filterInput, 'input', () => { this._filterText = this._filterInput!.value; @@ -508,6 +616,24 @@ export class ActionList extends Disposable { this._focusCheckedOrFirst(); } + // ArrowRight opens submenu for the focused item and moves focus into it + this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + const focused = this._list.getFocus(); + if (focused.length > 0) { + const element = this._list.element(focused[0]); + if (element?.submenuActions?.length) { + dom.EventHelper.stop(e, true); + const rowElement = this._getRowElement(focused[0]); + if (rowElement) { + this._showSubmenuForElement(element, rowElement); + this._currentSubmenuWidget?.focus(); + } + } + } + } + })); + // When the list has focus and user types a printable character, // forward it to the filter input so search begins automatically. if (this._filterInput) { @@ -531,6 +657,7 @@ export class ActionList extends Disposable { } else { this._collapsedSections.add(section); } + this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); } @@ -609,35 +736,32 @@ export class ActionList extends Disposable { this._list.splice(0, this._list.length, visible); - // Re-layout to adjust height after items changed - if (this._hasLaidOut) { - this.layout(this._lastMinWidth); - // Restore focus after splice destroyed DOM elements, - // otherwise the blur handler in ActionWidgetService closes the widget. - // Keep focus on the filter input if the user is typing a filter. - if (filterInputHasFocus) { - this._filterInput?.focus(); - // Keep a highlighted item in the list so Enter works without pressing DownArrow first - this._focusCheckedOrFirst(); - } else { - this._list.domFocus(); - // Restore focus to the previously focused item - if (focusedItem) { - const focusedItemId = (focusedItem.item as { id?: string })?.id; - if (focusedItemId) { - for (let i = 0; i < this._list.length; i++) { - const el = this._list.element(i); - if ((el.item as { id?: string })?.id === focusedItemId) { - this._list.setFocus([i]); - this._list.reveal(i); - break; - } + // Notify the parent that a re-layout is needed + this._onDidRequestLayout.fire(); + + // Restore focus after splice destroyed DOM elements, + // otherwise the blur handler in ActionWidgetService closes the widget. + // Keep focus on the filter input if the user is typing a filter. + if (filterInputHasFocus) { + this._filterInput?.focus(); + // Keep a highlighted item in the list so Enter works without pressing DownArrow first + this._focusCheckedOrFirst(); + } else { + this._list.domFocus(); + // Restore focus to the previously focused item + if (focusedItem) { + const focusedItemId = (focusedItem.item as { id?: string })?.id; + if (focusedItemId) { + for (let i = 0; i < this._list.length; i++) { + const el = this._list.element(i); + if ((el.item as { id?: string })?.id === focusedItemId) { + this._list.setFocus([i]); + this._list.reveal(i); + break; } } } } - // Reposition the context view so the widget grows in the correct direction - this._contextViewService.layout(); } } @@ -649,8 +773,6 @@ export class ActionList extends Disposable { return this._filterContainer; } - - get filterInput(): HTMLInputElement | undefined { return this._filterInput; } @@ -670,6 +792,14 @@ export class ActionList extends Disposable { this._focusCheckedOrFirst(); } + getFocusedElement(): IActionListItem | undefined { + const focused = this._list.getFocus(); + if (focused.length > 0) { + return this._list.element(focused[0]); + } + return undefined; + } + private _focusCheckedOrFirst(): void { this._suppressHover = true; try { @@ -697,7 +827,7 @@ export class ActionList extends Disposable { this._delegate.onHide(didCancel); this.cts.cancel(); this._hover.clear(); - this._contextViewService.hideContextView(); + this._hideSubmenu(); } clearFilter(): boolean { @@ -710,15 +840,42 @@ export class ActionList extends Disposable { return false; } - private hasDynamicHeight(): boolean { + /** + * Whether this widget uses dynamic height (has filter or collapsible sections). + */ + get hasDynamicHeight(): boolean { if (this._options?.showFilter) { return true; } return this._allMenuItems.some(item => item.isSectionToggle); } - private computeHeight(): number { - // Compute height based on currently visible items in the list + /** + * The height of a single action row in pixels. + */ + get lineHeight(): number { + return this._actionLineHeight; + } + + /** + * Computes the total height of all items (including collapsed/filtered items). + */ + computeFullHeight(): number { + let fullHeight = 0; + for (const item of this._allMenuItems) { + switch (item.kind) { + case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break; + case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break; + default: fullHeight += this._actionLineHeight; break; + } + } + return fullHeight; + } + + /** + * Computes the total height of visible items in the list. + */ + computeListHeight(): number { const visibleCount = this._list.length; let listHeight = 0; for (let i = 0; i < visibleCount; i++) { @@ -735,46 +892,23 @@ export class ActionList extends Disposable { break; } } + return listHeight; + } - const filterHeight = this._filterContainer ? 36 : 0; - const padding = 10; - const targetWindow = dom.getWindow(this.domNode); - let availableHeight; - - if (this.hasDynamicHeight()) { - const viewportHeight = targetWindow.innerHeight; - const anchorRect = getAnchorRect(this._anchor); - const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; - const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; - const spaceAbove = anchorTopInViewport - padding; + /** + * Lays out the list widget with the given explicit dimensions. + */ + layout(height: number, width?: number): void { + this._list.layout(height, width); + this.domNode.style.height = `${height}px`; - // Lock the direction on first layout based on whether the full - // unconstrained list fits below. Once decided, the dropdown stays - // in the same position even when the visible item count changes. - if (this._showAbove === undefined) { - let fullHeight = filterHeight; - for (const item of this._allMenuItems) { - switch (item.kind) { - case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break; - case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break; - default: fullHeight += this._actionLineHeight; break; - } - } - this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow; - } - availableHeight = this._showAbove ? spaceAbove : spaceBelow; - } else { - const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; - const widgetTop = this.domNode.getBoundingClientRect().top; - availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + // Place filter container on the preferred side. + if (this._filterContainer && this._filterContainer.parentElement) { + this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); } - - const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); - const height = Math.min(listHeight + filterHeight, maxHeight); - return height - filterHeight; } - private computeMaxWidth(minWidth: number): number { + computeMaxWidth(minWidth: number): number { const visibleCount = this._list.length; const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); let maxWidth = effectiveMinWidth; @@ -784,10 +918,6 @@ export class ActionList extends Disposable { return Math.max(380, effectiveMinWidth); } - if (this._cachedMaxWidth !== undefined) { - return this._cachedMaxWidth; - } - if (totalItemCount > visibleCount) { // Temporarily splice in all items to measure widths, // preventing width jumps when expanding/collapsing sections. @@ -815,7 +945,7 @@ export class ActionList extends Disposable { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + itemWidths.push(width + this._computeToolbarWidth(allItems[i])); } } @@ -834,31 +964,12 @@ export class ActionList extends Disposable { element.style.width = 'auto'; const width = element.getBoundingClientRect().width; element.style.width = ''; - itemWidths.push(width); + itemWidths.push(width + this._computeToolbarWidth(this._list.element(i))); } } return Math.max(...itemWidths, effectiveMinWidth); } - layout(minWidth: number): number { - this._hasLaidOut = true; - this._lastMinWidth = minWidth; - - const listHeight = this.computeHeight(); - this._list.layout(listHeight); - - this._cachedMaxWidth = this.computeMaxWidth(minWidth); - this._list.layout(listHeight, this._cachedMaxWidth); - this.domNode.style.height = `${listHeight}px`; - - // Place filter container on the preferred side. - if (this._filterContainer && this._filterContainer.parentElement) { - this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); - } - - return this._cachedMaxWidth; - } - focusPrevious() { if (this._filterInput && dom.isActiveElement(this._filterInput)) { this._list.domFocus(); @@ -995,6 +1106,14 @@ export class ActionList extends Disposable { this._list.setSelection([]); return; } + // Don't select when clicking the submenu indicator + if (element.submenuActions?.length && dom.isMouseEvent(e.browserEvent)) { + const target = e.browserEvent.target; + if (dom.isHTMLElement(target) && target.closest('.action-list-submenu-indicator')) { + this._list.setSelection([]); + return; + } + } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -1017,38 +1136,228 @@ export class ActionList extends Disposable { } } + private _removeItem(item: IActionListItem): void { + const index = this._allMenuItems.indexOf(item); + if (index >= 0) { + this._allMenuItems.splice(index, 1); + this._applyFilter(); + } + } + + private _computeToolbarWidth(item: IActionListItem): number { + let actionCount = item.toolbarActions?.length ?? 0; + if (item.onRemove) { + actionCount++; + } + if (actionCount === 0) { + return 0; + } + // Each toolbar action button is ~22px (16px icon + padding) plus 6px row gap + const actionButtonWidth = 22; + return actionCount * actionButtonWidth + 6; + } + private _getRowElement(index: number): HTMLElement | null { // eslint-disable-next-line no-restricted-syntax return this.domNode.ownerDocument.getElementById(this._list.getElementID(index)); } private _showHoverForElement(element: IActionListItem, index: number): void { - let newHover: IHoverWidget | undefined; + if (this._currentSubmenuElement === element || element.submenuActions?.length) { + return; + } + this._submenuDisposables.clear(); - // Show hover if the element has hover content - if (element.hover?.content) { - // The List widget separates data models from DOM elements, so we need to - // look up the actual DOM node to use as the hover target. - const rowElement = this._getRowElement(index); - if (rowElement) { - const markdown = typeof element.hover.content === 'string' ? new MarkdownString(element.hover.content) : element.hover.content; - newHover = this._hoverService.showDelayedHover({ - content: markdown ?? '', - target: rowElement, - additionalClasses: ['action-widget-hover'], - position: { - hoverPosition: HoverPosition.LEFT, - forcePosition: false, - ...element.hover.position, - }, - appearance: { - showPointer: true, - }, - }, { groupId: `actionListHover` }); + const rowElement = this._getRowElement(index); + if (!rowElement) { + this._hover.clear(); + return; + } + + const hasHoverContent = !!element.hover?.content; + + if (!hasHoverContent) { + this._hover.clear(); + return; + } + + const markdown = typeof element.hover!.content === 'string' ? new MarkdownString(element.hover!.content) : element.hover!.content; + const linkHandler = this._options?.linkHandler; + this._hover.value = this._hoverService.showDelayedHover({ + content: markdown ?? '', + target: rowElement, + additionalClasses: ['action-widget-hover'], + linkHandler: linkHandler ? (url: string) => { + linkHandler(URI.parse(url), element); + } : undefined, + position: { + hoverPosition: HoverPosition.LEFT, + forcePosition: false, + ...element.hover!.position, + }, + appearance: { + showPointer: true, + }, + }, { groupId: `actionListHover` }); + } + + private _showSubmenuForElement(element: IActionListItem, anchor: HTMLElement): void { + this._submenuDisposables.clear(); + this._hover.clear(); + this._currentSubmenuElement = element; + dom.clearNode(this._submenuContainer); + + // Convert submenu actions into ActionListWidget items + const submenuItems: IActionListItem[] = []; + for (const action of element.submenuActions!) { + if (action instanceof SubmenuAction) { + // Add header for the group + submenuItems.push({ + kind: ActionListItemKind.Header, + group: { title: action.label }, + label: action.label, + }); + // Add each child action as a selectable item + for (const child of action.actions) { + submenuItems.push({ + item: child, + kind: ActionListItemKind.Action, + label: child.label, + description: child.tooltip || undefined, + group: { title: '', icon: ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + hover: {}, + }); + } } } - this._hover.value = newHover; + const submenuDelegate: IActionListDelegate = { + onHide: () => { }, + onSelect: (action) => { + action.run(); + // Also select the parent item in the main list + const parentItem = this._currentSubmenuElement?.item; + this._hideSubmenu(); + if (parentItem) { + this._delegate.onSelect(parentItem); + } + this.hide(); + }, + }; + + // Show container before creating widget so List can measure during construction + this._submenuContainer.style.display = ''; + this._submenuContainer.style.position = 'absolute'; + + // Position: prefer right side, fall back to left if not enough space + const anchorRect = anchor.getBoundingClientRect(); + const parentRect = this.domNode.getBoundingClientRect(); + + const submenuWidget = this._submenuDisposables.add(this._instantiationService.createInstance( + ActionListWidget, + 'submenu', + false, + submenuItems, + submenuDelegate, + undefined, + undefined, + )); + this._submenuContainer.appendChild(submenuWidget.domNode); + this._currentSubmenuWidget = submenuWidget; + + // Layout: first pass renders items, second pass measures true width + const totalHeight = submenuWidget.computeListHeight(); + submenuWidget.layout(totalHeight); + const maxWidth = submenuWidget.computeMaxWidth(0); + submenuWidget.layout(totalHeight, maxWidth); + submenuWidget.domNode.style.width = `${maxWidth}px`; + + // Position: prefer right side, fall back to left if not enough space + const targetWindow = dom.getWindow(this.domNode); + const viewportWidth = targetWindow.innerWidth; + const spaceRight = viewportWidth - anchorRect.right; + const spaceLeft = parentRect.left; + const submenuWidth = maxWidth + 10; // account for border/padding + + const gap = 4; + if (spaceRight >= submenuWidth || spaceRight >= spaceLeft) { + // Show on the right, offset past the parent's right edge + this._submenuContainer.style.left = `${parentRect.right - parentRect.left + gap}px`; + } else { + // Show on the left + this._submenuContainer.style.left = `${-submenuWidth - gap}px`; + } + this._submenuContainer.style.top = `${anchorRect.top - parentRect.top - 4}px`; + + // Keyboard navigation in submenu + this._submenuDisposables.add(dom.addDisposableListener(submenuWidget.domNode, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + dom.EventHelper.stop(e, true); + this._hideSubmenu(); + this._list.domFocus(); + } else if (e.key === 'Enter') { + dom.EventHelper.stop(e, true); + const focused = submenuWidget.getFocusedElement(); + if (focused?.item) { + focused.item.run(); + const parentItem = this._currentSubmenuElement?.item; + this._hideSubmenu(); + if (parentItem) { + this._delegate.onSelect(parentItem); + } + this.hide(); + } + } else if (e.key === 'ArrowDown') { + dom.EventHelper.stop(e, true); + submenuWidget.focusNext(); + } else if (e.key === 'ArrowUp') { + dom.EventHelper.stop(e, true); + submenuWidget.focusPrevious(); + } + })); + } + + private _hideSubmenu(): void { + this._cancelSubmenuHide(); + this._cancelSubmenuShow(); + this._submenuDisposables.clear(); + this._currentSubmenuWidget = undefined; + this._currentSubmenuElement = undefined; + dom.clearNode(this._submenuContainer); + this._submenuContainer.style.display = 'none'; + } + + private _scheduleSubmenuHide(): void { + this._cancelSubmenuHide(); + this._submenuHideTimeout = setTimeout(() => { + this._hideSubmenu(); + }, 300); + } + + private _cancelSubmenuHide(): void { + if (this._submenuHideTimeout !== undefined) { + clearTimeout(this._submenuHideTimeout); + this._submenuHideTimeout = undefined; + } + } + + private _scheduleSubmenuShow(element: IActionListItem, index: number | undefined): void { + this._cancelSubmenuShow(); + this._submenuShowTimeout = setTimeout(() => { + this._submenuShowTimeout = undefined; + const rowElement = typeof index === 'number' ? this._getRowElement(index) : null; + if (rowElement) { + this._showSubmenuForElement(element, rowElement); + } + }, 300); + } + + private _cancelSubmenuShow(): void { + if (this._submenuShowTimeout !== undefined) { + clearTimeout(this._submenuShowTimeout); + this._submenuShowTimeout = undefined; + } } private async onListHover(e: IListMouseEvent>) { @@ -1056,9 +1365,10 @@ export class ActionList extends Disposable { if (element && element.item && this.focusCondition(element)) { // Check if the hover target is inside a toolbar - if so, skip the splice - // to avoid re-rendering which would destroy the toolbar mid-hover + // to avoid re-rendering which would destroy the element mid-hover const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null; if (isHoveringToolbar) { + this._cancelSubmenuShow(); this._list.setFocus([]); return; } @@ -1066,7 +1376,25 @@ export class ActionList extends Disposable { // Set focus immediately for responsive hover feedback this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); - if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) { + // Show submenu on row hover for items with submenu actions + if (element.submenuActions?.length) { + if (this._currentSubmenuElement === element) { + this._cancelSubmenuHide(); + this._cancelSubmenuShow(); + } else { + this._scheduleSubmenuShow(element, e.index); + } + return; + } + + if (this._currentSubmenuElement === element) { + this._cancelSubmenuHide(); + } else { + this._cancelSubmenuShow(); + this._hideSubmenu(); + } + + if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action && this._currentSubmenuElement !== element) { const result = await this._delegate.onHover(element.item, this.cts.token); const canPreview = result ? result.canPreview : undefined; if (canPreview !== element.canPreview) { @@ -1095,6 +1423,167 @@ export class ActionList extends Disposable { } } +/** + * An action list that wraps {@link ActionListWidget} with context-view positioning + * and anchor-based height computation. + */ +export class ActionList extends Disposable { + + private readonly _widget: ActionListWidget; + + private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor; + private _lastMinWidth = 0; + private _cachedMaxWidth: number | undefined; + private _hasLaidOut = false; + private _showAbove: boolean | undefined; + + get domNode(): HTMLElement { + return this._widget.domNode; + } + + get filterContainer(): HTMLElement | undefined { + return this._widget.filterContainer; + } + + get filterInput(): HTMLInputElement | undefined { + return this._widget.filterInput; + } + + /** + * Returns the resolved anchor position after the first layout. + * Used by the context view delegate to lock the dropdown direction. + */ + get anchorPosition(): AnchorPosition | undefined { + if (this._showAbove === undefined) { + return undefined; + } + return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + + constructor( + user: string, + preview: boolean, + items: readonly IActionListItem[], + _delegate: IActionListDelegate, + accessibilityProvider: Partial>> | undefined, + options: IActionListOptions | undefined, + anchor: HTMLElement | StandardMouseEvent | IAnchor, + @IContextViewService private readonly _contextViewService: IContextViewService, + @ILayoutService private readonly _layoutService: ILayoutService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._anchor = anchor; + + this._widget = this._register(instantiationService.createInstance( + ActionListWidget, + user, + preview, + items, + _delegate, + accessibilityProvider, + options, + )); + + this._register(this._widget.onDidRequestLayout(() => { + if (this._hasLaidOut) { + this.layout(this._lastMinWidth); + this._contextViewService.layout(); + } + })); + } + + focus(): void { + this._widget.focus(); + } + + hide(didCancel?: boolean): void { + this._widget.hide(didCancel); + this._contextViewService.hideContextView(); + } + + clearFilter(): boolean { + return this._widget.clearFilter(); + } + + focusPrevious(): void { + this._widget.focusPrevious(); + } + + focusNext(): void { + this._widget.focusNext(); + } + + collapseFocusedSection(): void { + this._widget.collapseFocusedSection(); + } + + expandFocusedSection(): void { + this._widget.expandFocusedSection(); + } + + toggleFocusedSection(): boolean { + return this._widget.toggleFocusedSection(); + } + + acceptSelected(preview?: boolean): void { + this._widget.acceptSelected(preview); + } + + private hasDynamicHeight(): boolean { + return this._widget.hasDynamicHeight; + } + + private computeHeight(): number { + const listHeight = this._widget.computeListHeight(); + + const filterHeight = this._widget.filterContainer ? 36 : 0; + const padding = 10; + const targetWindow = dom.getWindow(this.domNode); + let availableHeight; + + if (this.hasDynamicHeight()) { + const viewportHeight = targetWindow.innerHeight; + const anchorRect = getAnchorRect(this._anchor); + const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; + const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; + const spaceAbove = anchorTopInViewport - padding; + + // Lock the direction on first layout based on whether the full + // unconstrained list fits below. Once decided, the dropdown stays + // in the same position even when the visible item count changes. + if (this._showAbove === undefined) { + const fullHeight = filterHeight + this._widget.computeFullHeight(); + this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow; + } + availableHeight = this._showAbove ? spaceAbove : spaceBelow; + } else { + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this.domNode.getBoundingClientRect().top; + availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + } + + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.6); + const actionLineHeight = this._widget.lineHeight; + const maxHeight = Math.min(Math.max(availableHeight, actionLineHeight * 3 + filterHeight), viewportMaxHeight); + const height = Math.min(listHeight + filterHeight, maxHeight); + return height - filterHeight; + } + + layout(minWidth: number): number { + this._hasLaidOut = true; + this._lastMinWidth = minWidth; + + const listHeight = this.computeHeight(); + this._widget.layout(listHeight); + + this._cachedMaxWidth = this._widget.computeMaxWidth(minWidth); + this._widget.layout(listHeight, this._cachedMaxWidth); + + return this._cachedMaxWidth; + } +} + function stripNewlines(str: string): string { return str.replace(/\r\n|\r|\n/g, ' '); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 96fc6cbf50661..d9bc575699c7a 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -15,7 +15,7 @@ background-color: var(--vscode-menu-background); color: var(--vscode-menu-foreground); padding: 4px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); } .context-view-block { @@ -55,7 +55,7 @@ /** Styles for each row in the list element **/ .action-widget .monaco-list .monaco-list-row { - padding: 0 4px 0 8px; + padding: 0 12px 0 8px; white-space: nowrap; cursor: pointer; touch-action: none; @@ -217,6 +217,31 @@ font-size: 12px; } +/* Description below mode — shows descriptions as subtext under the title */ +.action-widget .description-below .monaco-list .monaco-list-row.action { + flex-wrap: wrap; + align-content: center; + padding-top: 6px; + padding-right: 2px; + + .title { + line-height: 14px; + } + + .description { + display: block !important; + width: 100%; + margin-left: 0; + padding-left: 20px; + font-size: 11px; + line-height: 14px; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -240,7 +265,14 @@ /* Filter input */ .action-widget .action-list-filter { - padding: 2px 2px 4px 2px + padding: 2px 2px 4px 2px; +} + +.action-widget .action-list-filter-row { + display: flex; + align-items: center; + gap: 4px; + padding-right: 10px; } .action-widget .action-list-filter:first-child { @@ -253,6 +285,7 @@ .action-widget .action-list-filter-input { width: 100%; + flex: 1; box-sizing: border-box; padding: 4px 8px; border: 1px solid var(--vscode-input-border, transparent); @@ -269,3 +302,46 @@ .action-widget .action-list-filter-input::placeholder { color: var(--vscode-input-placeholderForeground); } + +.action-widget .action-list-filter-actions .action-label { + padding: 3px; + border-radius: 3px; +} + +.action-widget .action-list-filter-actions .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Anchor for the absolutely-positioned submenu panel */ +.action-widget .actionList { + position: relative; +} + +.action-widget .action-list-submenu-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + border-radius: 4px; +} + +.action-widget .action-list-submenu-indicator.has-submenu { + opacity: 0.6; +} + +.action-widget .monaco-list-row.action .action-list-submenu-indicator.codicon { + display: flex; + font-size: 16px; +} + +.action-list-submenu-panel { + background-color: var(--vscode-menu-background); + color: var(--vscode-menu-foreground); + border: 1px solid var(--vscode-menu-border, var(--vscode-editorHoverWidget-border)); + border-radius: 5px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + z-index: 50; + width: fit-content; +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 58792a384ff5a..32af92b7733ac 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -174,9 +174,9 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { const focusTracker = renderDisposables.add(dom.trackFocus(element)); renderDisposables.add(focusTracker.onDidBlur(() => { - // Don't hide if focus moved to a hover that belongs to this action widget + // Don't hide if focus moved to a hover or submenu that belongs to this action widget const activeElement = dom.getActiveElement(); - if (activeElement?.closest('.action-widget-hover')) { + if (activeElement?.closest('.action-widget-hover') || activeElement?.closest('.action-list-submenu-panel')) { return; } this.hide(true); diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 2b2022fb43572..82e6ff581bad9 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -170,6 +170,7 @@ export class ActionWidgetDropdown extends BaseDropdown { action.run(); }, onHide: () => { + this.hide(); if (isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -221,6 +222,8 @@ export class ActionWidgetDropdown extends BaseDropdown { getWidgetRole: () => 'menu', }; + super.show(); + this.actionWidgetService.show( this._options.label ?? '', false, diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index a167319371ca0..e44cdb4eae07e 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -15,7 +15,7 @@ import { Iterable } from '../../../base/common/iterator.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { createActionViewItem, getActionBarActions } from './menuEntryActionViewItem.js'; -import { IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; import { createConfigureKeybindingAction } from '../common/menuService.js'; import { ICommandService } from '../../commands/common/commands.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; @@ -184,7 +184,8 @@ export class WorkbenchToolBar extends ToolBar { // coalesce turns Array into IAction[] coalesceInPlace(primary); coalesceInPlace(extraSecondary); - super.setActions(primary, Separator.join(extraSecondary, secondary)); + + super.setActions(Separator.clean(primary), Separator.join(extraSecondary, secondary)); // add context menu for toggle and configure keybinding actions if (toggleActions.length > 0 || primary.length > 0) { @@ -332,6 +333,11 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { private readonly _onDidChangeMenuItems = this._store.add(new Emitter()); get onDidChangeMenuItems() { return this._onDidChangeMenuItems.event; } + private readonly _menu: IMenu; + private readonly _menuOptions: IMenuActionOptions | undefined; + private readonly _toolbarOptions: IToolBarRenderOptions | undefined; + private readonly _container: HTMLElement; + constructor( container: HTMLElement, menuId: MenuId, @@ -361,30 +367,44 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { } }, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + this._container = container; + this._menuOptions = options?.menuOptions; + this._toolbarOptions = options?.toolbarOptions; + // update logic - const menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); - const updateToolbar = () => { - const { primary, secondary } = getActionBarActions( - menu.getActions(options?.menuOptions), - options?.toolbarOptions?.primaryGroup, - options?.toolbarOptions?.shouldInlineSubmenu, - options?.toolbarOptions?.useSeparatorsInPrimaryActions - ); - container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); - super.setActions(primary, secondary); - }; - - this._store.add(menu.onDidChange(() => { - updateToolbar(); + this._menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); + + this._store.add(this._menu.onDidChange(() => { + this._updateToolbar(); this._onDidChangeMenuItems.fire(this); })); this._store.add(actionViewService.onDidChange(e => { if (e === menuId) { - updateToolbar(); + this._updateToolbar(); } })); - updateToolbar(); + this._updateToolbar(); + } + + private _updateToolbar(): void { + const { primary, secondary } = getActionBarActions( + this._menu.getActions(this._menuOptions), + this._toolbarOptions?.primaryGroup, + this._toolbarOptions?.shouldInlineSubmenu, + this._toolbarOptions?.useSeparatorsInPrimaryActions + ); + this._container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); + super.setActions(primary, secondary); + } + + /** + * Force the toolbar to immediately re-evaluate its menu actions. + * Use this after synchronously updating context keys to avoid + * layout shifts caused by the debounced menu change event. + */ + refresh(): void { + this._updateToolbar(); } /** diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1b3e9d595c887..b835d1cdd087d 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -89,6 +89,7 @@ export class MenuId { static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); static readonly ModalEditorTitle = new MenuId('ModalEditorTitle'); + static readonly ModalEditorEditorTitle = new MenuId('ModalEditorEditorTitle'); static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle'); static readonly EditorTitleRun = new MenuId('EditorTitleRun'); static readonly EditorTitleContext = new MenuId('EditorTitleContext'); @@ -169,6 +170,7 @@ export class MenuId { static readonly TestCoverageFilterItem = new MenuId('TestCoverageFilterItem'); static readonly TouchBarContext = new MenuId('TouchBarContext'); static readonly TitleBar = new MenuId('TitleBar'); + static readonly TitleBarAdjacentCenter = new MenuId('TitleBarAdjacentCenter'); static readonly TitleBarContext = new MenuId('TitleBarContext'); static readonly TitleBarTitleContext = new MenuId('TitleBarTitleContext'); static readonly TunnelContext = new MenuId('TunnelContext'); @@ -255,10 +257,15 @@ export class MenuId { static readonly ChatExecute = new MenuId('ChatExecute'); static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); + static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); + static readonly ChatEditingSessionApplySubmenu = new MenuId('ChatEditingSessionApplySubmenu'); + static readonly ChatEditingSessionTitleToolbar = new MenuId('ChatEditingSessionTitleToolbar'); + static readonly ChatEditingSessionChangeToolbar = new MenuId('ChatEditingSessionChangeToolbar'); + static readonly ChatEditingSessionChangesVersionsSubmenu = new MenuId('ChatEditingSessionChangesVersionsSubmenu'); static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent'); static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk'); static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell'); @@ -304,6 +311,7 @@ export class MenuId { static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); static readonly ChatContextUsageActions = new MenuId('ChatContextUsageActions'); + static readonly MarkerHoverStatusBar = new MenuId('MarkerHoverParticipant.StatusBar'); /** * Create or reuse a `MenuId` with the given identifier diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts new file mode 100644 index 0000000000000..c649a047d9980 --- /dev/null +++ b/src/vs/platform/agentHost/common/agent.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; + +// Agent host process starter and connection abstractions. +// Used by the main process to spawn and connect to the agent host utility process. + +export interface IAgentHostConnection { + readonly client: IChannelClient; + readonly store: DisposableStore; + readonly onDidProcessExit: Event<{ code: number; signal: string }>; +} + +export interface IAgentHostStarter extends IDisposable { + readonly onRequestConnection?: Event; + readonly onWillShutdown?: Event; + + /** + * Creates the agent host utility process and connects to it. + */ + start(): IAgentHostConnection; +} diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts new file mode 100644 index 0000000000000..54eba157fb3f6 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../base/common/buffer.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { basename, dirname } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { createFileSystemProviderError, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProvider, IFileWriteOptions, IStat } from '../../files/common/files.js'; +import { type IAgentConnection } from './agentService.js'; +import { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; +import { IDirectoryEntry } from './state/protocol/commands.js'; + + +/** + * Build a {@link AGENT_HOST_SCHEME} URI for a given connection authority + * and remote path. Assumes the remote path is a `file://` resource. + */ +export function agentHostUri(authority: string, path: string): URI { + return toAgentHostUri(URI.file(path), authority); +} + +/** + * Extract the remote filesystem path from a {@link AGENT_HOST_SCHEME} URI. + */ +export function agentHostRemotePath(uri: URI): string { + return fromAgentHostUri(uri).path; +} + +/** + * Read-only {@link IFileSystemProvider} that proxies filesystem operations + * through the agent host protocol. + * + * Registered under the {@link AGENT_HOST_SCHEME} scheme. URIs encode the + * original scheme and authority in the path so any remote resource can be + * represented (not just `file://`): + * + * ``` + * vscode-agent-host://[connectionAuthority]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * Individual connections are identified by the URI's authority component, + * which is the sanitized remote address. + */ +export class AgentHostFileSystemProvider extends Disposable implements IFileSystemProvider { + + readonly capabilities = + FileSystemProviderCapabilities.Readonly | + FileSystemProviderCapabilities.PathCaseSensitive | + FileSystemProviderCapabilities.FileReadWrite; // required for the file service to resolve directory contents + + private readonly _onDidChangeCapabilities = this._register(new Emitter()); + readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; + + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private readonly _authorityToConnection = new Map(); + + /** + * Register a mapping from a URI authority to an agent connection. + * Returns a disposable that unregisters the mapping. + */ + registerAuthority(authority: string, connection: IAgentConnection): IDisposable { + this._authorityToConnection.set(authority, connection); + return toDisposable(() => this._authorityToConnection.delete(authority)); + } + + watch(): IDisposable { + return Disposable.None; + } + + async stat(resource: URI): Promise { + const path = resource.path; + + // Root directory - either the bare scheme root or the root of the + // decoded remote filesystem (e.g. `/file/-/` decodes to `file:///`). + if (path === '/' || path === '') { + return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + const decodedPath = fromAgentHostUri(resource).path; + if (decodedPath === '/' || decodedPath === '') { + return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + + // Use URI dirname/basename to find the parent and entry name + const parentUri = dirname(resource); + const name = basename(resource); + + const entries = await this._listDirectory(resource.authority, parentUri); + const entry = entries.find(e => e.name === name); + if (!entry) { + throw createFileSystemProviderError(`File not found: ${path}`, FileSystemProviderErrorCode.FileNotFound); + } + + return { + type: entry.type === 'directory' ? FileType.Directory : FileType.File, + mtime: 0, + ctime: 0, + size: 0, + permissions: FilePermission.Readonly, + }; + } + + async readdir(resource: URI): Promise<[string, FileType][]> { + const entries = await this._listDirectory(resource.authority, resource); + return entries.map(e => [e.name, e.type === 'directory' ? FileType.Directory : FileType.File]); + } + + // ---- Read-only stubs (required by interface) ---------------------------- + + async readFile(resource: URI): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = fromAgentHostUri(resource); + const result = await connection.fetchContent(originalUri); + return VSBuffer.fromString(result.data).buffer; + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.FileNotFound, + ); + } + } + + async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { + throw createFileSystemProviderError('writeFile not supported on remote agent host filesystem', FileSystemProviderErrorCode.NoPermissions); + } + + async mkdir(): Promise { + throw createFileSystemProviderError('mkdir not supported on remote agent host filesystem', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('delete not supported on remote agent host filesystem', FileSystemProviderErrorCode.NoPermissions); + } + + async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('rename not supported on remote agent host filesystem', FileSystemProviderErrorCode.NoPermissions); + } + + // ---- Internals ---------------------------------------------------------- + + private _getConnection(authority: string) { + const connection = this._authorityToConnection.get(authority); + if (!connection) { + throw createFileSystemProviderError(`No connection for authority: ${authority}`, FileSystemProviderErrorCode.Unavailable); + } + return connection; + } + + private async _listDirectory(authority: string, resource: URI): Promise { + const connection = this._getConnection(authority); + try { + const originalUri = fromAgentHostUri(resource); + const result = await connection.browseDirectory(originalUri); + return result.entries; + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.Unavailable, + ); + } + } +} diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts new file mode 100644 index 0000000000000..773bce4e67643 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; +import type { ResourceLabelFormatter } from '../../label/common/label.js'; + +/** + * The URI scheme for accessing files on a remote agent host. + * + * URIs encode the original scheme, authority, and path so that any + * remote resource can be represented without assuming `file://`: + * + * ``` + * vscode-agent-host://[connectionAuthority]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * For example, `file:///home/user/foo.ts` on remote `my-server` becomes: + * ``` + * vscode-agent-host://my-server/file//home/user/foo.ts + * ``` + */ +export const AGENT_HOST_SCHEME = 'vscode-agent-host'; + +/** + * Wraps a remote URI into a {@link AGENT_HOST_SCHEME} URI that can be + * resolved through the agent host filesystem provider. + * + * @param originalUri The URI on the remote (e.g. `file:///path` or + * `agenthost-content:///sessionId/...`) + * @param connectionAuthority The sanitized connection identifier used as + * the URI authority (from {@link agentHostAuthority}). + */ +export function toAgentHostUri(originalUri: URI, connectionAuthority: string): URI { + if (connectionAuthority === 'local') { + return originalUri; + } + + // Path format: /[originalScheme]/[originalAuthority]/[originalPath] + const originalAuthority = originalUri.authority || ''; + return URI.from({ + scheme: AGENT_HOST_SCHEME, + authority: connectionAuthority, + path: `/${originalUri.scheme}/${originalAuthority || '-'}${originalUri.path}`, + }); +} + +/** + * Extracts the original URI from a {@link AGENT_HOST_SCHEME} URI. + * + * The inverse of {@link toAgentHostUri}. + */ +export function fromAgentHostUri(agentHostUri: URI): URI { + // Path: /[originalScheme]/[originalAuthority]/[rest of original path] + const path = agentHostUri.path; + + // Find first segment boundary after leading / + const schemeEnd = path.indexOf('/', 1); + if (schemeEnd === -1) { + // Malformed — treat whole path as file scheme + return URI.from({ scheme: 'file', path }); + } + + const originalScheme = path.substring(1, schemeEnd); + + // Find second segment boundary (authority/path split) + const authorityEnd = path.indexOf('/', schemeEnd + 1); + if (authorityEnd === -1) { + // No path after authority + const originalAuthority = path.substring(schemeEnd + 1); + return URI.from({ scheme: originalScheme, authority: originalAuthority, path: '/' }); + } + + let originalAuthority = path.substring(schemeEnd + 1, authorityEnd); + if (originalAuthority === '-') { + originalAuthority = ''; + } + + const originalPath = path.substring(authorityEnd); + + return URI.from({ + scheme: originalScheme, + authority: originalAuthority || undefined, + path: originalPath, + }); +} + +/** + * Strips the redundant `ws://` scheme from an address. The transport layer + * already defaults to `ws://`, so only `wss://` needs to be preserved. + */ +export function normalizeRemoteAgentHostAddress(address: string): string { + if (address.startsWith('ws://')) { + return address.slice('ws://'.length); + } + return address; +} + +/** + * Encode a remote address into an identifier that is safe for use in + * both URI schemes and URI authorities, and is collision-free. + * + * Three tiers: + * 1. Purely alphanumeric addresses are returned as-is. + * 2. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons + * replaced with `__` (double underscore) for human readability. + * Addresses containing `_` skip this tier to keep the encoding + * collision-free (`__` can only appear from colon replacement). + * 3. Everything else is url-safe base64-encoded with a `b64-` prefix. + */ +export function agentHostAuthority(address: string): string { + const normalized = normalizeRemoteAgentHostAddress(address); + if (/^[a-zA-Z0-9]+$/.test(normalized)) { + return normalized; + } + if (/^[a-zA-Z0-9.:\-]+$/.test(normalized)) { + return normalized.replaceAll(':', '__'); + } + return 'b64-' + encodeBase64(VSBuffer.fromString(normalized), false, true); +} + +/** + * Label formatter for {@link AGENT_HOST_SCHEME} URIs. Strips the two + * leading path segments (`/scheme/authority`) to display the original + * file path. + */ +export const AGENT_HOST_LABEL_FORMATTER: ResourceLabelFormatter = { + scheme: AGENT_HOST_SCHEME, + formatting: { + label: '${path}', + separator: '/', + stripPathSegments: 2, + }, +}; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts new file mode 100644 index 0000000000000..701d346ff1d50 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -0,0 +1,471 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from './state/sessionProtocol.js'; +import { AttachmentType, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; + +// IPC contract between the renderer and the agent host utility process. +// Defines all serializable event types, the IAgent provider interface, +// and the IAgentService / IAgentHostService service decorators. + +export const enum AgentHostIpcChannels { + /** Channel for the agent host service on the main-process side */ + AgentHost = 'agentHost', + /** Channel for log forwarding from the agent host process */ + Logger = 'agentHostLogger', + /** Channel for WebSocket client connection count (server process management only) */ + ConnectionTracker = 'agentHostConnectionTracker', +} + +/** Configuration key that controls whether the agent host process is spawned. */ +export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; + +/** Configuration key that controls whether per-host IPC traffic output channels are created. */ +export const AgentHostIpcLoggingSettingId = 'chat.agentHost.ipcLoggingEnabled'; + +// ---- IPC data types (serializable across MessagePort) ----------------------- + +export interface IAgentSessionMetadata { + readonly session: URI; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; +} + +export type AgentProvider = string; + +/** Metadata describing an agent backend, discovered over IPC. */ +export interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; + /** + * Whether the renderer should push a GitHub auth token for this agent. + * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. + */ + readonly requiresAuth: boolean; +} + +// ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 + * to describe auth requirements, enabling clients to resolve tokens + * using the standard VS Code authentication service. + * + * Returned from the server via {@link IAgentService.getResourceMetadata}. + */ +export interface IResourceMetadata { + /** + * Protected resources the agent host requires authentication for. + * Each entry uses the standard RFC 9728 shape so clients can resolve + * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. + */ + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). + */ +export interface IAuthenticateParams { + /** + * The `resource` identifier from the server's + * {@link IAuthorizationProtectedResourceMetadata} that this token targets. + */ + readonly resource: string; + + /** The bearer token value (RFC 6750). */ + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +export interface IAuthenticateResult { + /** Whether the token was accepted. */ + readonly authenticated: boolean; +} + +export interface IAgentCreateSessionConfig { + readonly provider?: AgentProvider; + readonly model?: string; + readonly session?: URI; + readonly workingDirectory?: string; +} + +/** Serializable attachment passed alongside a message to the agent host. */ +export interface IAgentAttachment { + readonly type: AttachmentType; + readonly path: string; + readonly displayName?: string; + /** For selections: the selected text. */ + readonly text?: string; + /** For selections: line/character range. */ + readonly selection?: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; +} + +/** Serializable model information from the agent host. */ +export interface IAgentModelInfo { + readonly provider: AgentProvider; + readonly id: string; + readonly name: string; + readonly maxContextWindow: number; + readonly supportsVision: boolean; + readonly supportsReasoningEffort: boolean; + readonly supportedReasoningEfforts?: readonly string[]; + readonly defaultReasoningEffort?: string; + readonly policyState?: PolicyState; + readonly billingMultiplier?: number; +} + +// ---- Progress events (discriminated union by `type`) ------------------------ + +interface IAgentProgressEventBase { + readonly session: URI; +} + +/** Streaming text delta from the assistant (`assistant.message_delta`). */ +export interface IAgentDeltaEvent extends IAgentProgressEventBase { + readonly type: 'delta'; + readonly messageId: string; + readonly content: string; + readonly parentToolCallId?: string; +} + +/** A complete assistant message (`assistant.message`), used for history reconstruction. */ +export interface IAgentMessageEvent extends IAgentProgressEventBase { + readonly type: 'message'; + readonly role: 'user' | 'assistant'; + readonly messageId: string; + readonly content: string; + readonly toolRequests?: readonly { + readonly toolCallId: string; + readonly name: string; + /** Serialized JSON of arguments, if available. */ + readonly arguments?: string; + readonly type?: 'function' | 'custom'; + }[]; + readonly reasoningOpaque?: string; + readonly reasoningText?: string; + readonly encryptedContent?: string; + readonly parentToolCallId?: string; +} + +/** The session has finished processing and is waiting for input (`session.idle`). */ +export interface IAgentIdleEvent extends IAgentProgressEventBase { + readonly type: 'idle'; +} + +/** A tool has started executing (`tool.execution_start`). */ +export interface IAgentToolStartEvent extends IAgentProgressEventBase { + readonly type: 'tool_start'; + readonly toolCallId: string; + readonly toolName: string; + /** Human-readable display name for this tool. */ + readonly displayName: string; + /** Message describing the tool invocation in progress (e.g., "Running `echo hello`"). */ + readonly invocationMessage: string; + /** A representative input string for display in the UI (e.g., the shell command). */ + readonly toolInput?: string; + /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands). */ + readonly toolKind?: 'terminal'; + /** Language identifier for syntax highlighting (e.g., 'shellscript', 'powershell'). Used with toolKind 'terminal'. */ + readonly language?: string; + /** Serialized JSON of the tool arguments, if available. */ + readonly toolArguments?: string; + readonly mcpServerName?: string; + readonly mcpToolName?: string; + readonly parentToolCallId?: string; +} + +/** A tool has finished executing (`tool.execution_complete`). */ +export interface IAgentToolCompleteEvent extends IAgentProgressEventBase { + readonly type: 'tool_complete'; + readonly toolCallId: string; + /** Tool execution result, matching the protocol {@link IToolCallResult} shape. */ + readonly result: IToolCallResult; + readonly isUserRequested?: boolean; + /** Serialized JSON of tool-specific telemetry data. */ + readonly toolTelemetry?: string; + readonly parentToolCallId?: string; +} + +/** The session title has been updated. */ +export interface IAgentTitleChangedEvent extends IAgentProgressEventBase { + readonly type: 'title_changed'; + readonly title: string; +} + +/** An error occurred during session processing. */ +export interface IAgentErrorEvent extends IAgentProgressEventBase { + readonly type: 'error'; + readonly errorType: string; + readonly message: string; + readonly stack?: string; +} + +/** Token usage information for a request. */ +export interface IAgentUsageEvent extends IAgentProgressEventBase { + readonly type: 'usage'; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly model?: string; + readonly cacheReadTokens?: number; +} + +/** + * A running tool requires re-confirmation (e.g. a mid-execution permission check). + * Maps to `SessionToolCallReady` without `confirmed` to transition Running → PendingConfirmation. + */ +export interface IAgentToolReadyEvent extends IAgentProgressEventBase { + readonly type: 'tool_ready'; + readonly toolCallId: string; + /** Message describing what confirmation is needed. */ + readonly invocationMessage: StringOrMarkdown; + /** Raw tool input to display. */ + readonly toolInput?: string; + /** Short title for the confirmation prompt. */ + readonly confirmationTitle?: StringOrMarkdown; +} + +/** Streaming reasoning/thinking content from the assistant. */ +export interface IAgentReasoningEvent extends IAgentProgressEventBase { + readonly type: 'reasoning'; + readonly content: string; +} + +export type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolReadyEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentReasoningEvent; + +// ---- Session URI helpers ---------------------------------------------------- + +export namespace AgentSession { + + /** + * Creates a session URI from a provider name and raw session ID. + * The URI scheme is the provider name (e.g., `copilot:/`). + */ + export function uri(provider: AgentProvider, rawSessionId: string): URI { + return URI.from({ scheme: provider, path: `/${rawSessionId}` }); + } + + /** + * Extracts the raw session ID from a session URI (the path without leading slash). + * Accepts both a URI object and a URI string. + */ + export function id(session: URI | string): string { + const parsed = typeof session === 'string' ? URI.parse(session) : session; + return parsed.path.substring(1); + } + + /** + * Extracts the provider name from a session URI scheme. + * Accepts both a URI object and a URI string. + */ + export function provider(session: URI | string): AgentProvider | undefined { + const parsed = typeof session === 'string' ? URI.parse(session) : session; + return parsed.scheme || undefined; + } +} + +// ---- Agent provider interface ----------------------------------------------- + +/** + * Implemented by each agent backend (e.g. Copilot SDK). + * The {@link IAgentService} dispatches to the appropriate agent based on + * the agent id. + */ +export interface IAgent { + /** Unique identifier for this provider (e.g. `'copilot'`). */ + readonly id: AgentProvider; + + /** Fires when the provider streams progress for a session. */ + readonly onDidSessionProgress: Event; + + /** Create a new session. Returns the session URI. */ + createSession(config?: IAgentCreateSessionConfig): Promise; + + /** Send a user message into an existing session. */ + sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise; + + /** Retrieve all session events/messages for reconstruction. */ + getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]>; + + /** Dispose a session, freeing resources. */ + disposeSession(session: URI): Promise; + + /** Abort the current turn, stopping any in-flight processing. */ + abortSession(session: URI): Promise; + + /** Change the model for an existing session. */ + changeModel(session: URI, model: string): Promise; + + /** Respond to a pending permission request from the SDK. */ + respondToPermissionRequest(requestId: string, approved: boolean): void; + + /** Return the descriptor for this agent. */ + getDescriptor(): IAgentDescriptor; + + /** List available models from this provider. */ + listModels(): Promise; + + /** List persisted sessions from this provider. */ + listSessions(): Promise; + + /** Declare protected resources this agent requires auth for (RFC 9728). */ + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + + /** + * Authenticate for a specific resource. Returns true if accepted. + * The `resource` matches {@link IAuthorizationProtectedResourceMetadata.resource}. + */ + authenticate(resource: string, token: string): Promise; + + /** Gracefully shut down all sessions. */ + shutdown(): Promise; + + /** Dispose this provider and all its resources. */ + dispose(): void; +} + +// ---- Service interfaces ----------------------------------------------------- + +export const IAgentService = createDecorator('agentService'); + +/** + * Service contract for communicating with the agent host process. Methods here + * are proxied across MessagePort via `ProxyChannel`. + * + * State is synchronized via the subscribe/unsubscribe/dispatchAction protocol. + * Clients observe root state (agents, models) and session state via subscriptions, + * and mutate state by dispatching actions (e.g. session/turnStarted, session/turnCancelled). + */ +export interface IAgentService { + readonly _serviceBrand: undefined; + + /** Discover available agent backends from the agent host. */ + listAgents(): Promise; + + /** + * Retrieve the resource metadata describing auth requirements. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ + getResourceMetadata(): Promise; + + /** + * Authenticate for a protected resource on the server. + * The {@link IAuthenticateParams.resource} must match a resource from + * {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery. + */ + authenticate(params: IAuthenticateParams): Promise; + + /** + * Refresh the model list from all providers, publishing updated + * agents (with models) to root state via `root/agentsChanged`. + */ + refreshModels(): Promise; + + /** List all available sessions from the Copilot CLI. */ + listSessions(): Promise; + + /** Create a new session. Returns the session URI. */ + createSession(config?: IAgentCreateSessionConfig): Promise; + + /** Dispose a session in the agent host, freeing SDK resources. */ + disposeSession(session: URI): Promise; + + /** Gracefully shut down all sessions and the underlying client. */ + shutdown(): Promise; + + // ---- Protocol methods (sessions process protocol) ---------------------- + + /** + * Subscribe to state at the given URI. Returns a snapshot of the current + * state and the serverSeq at snapshot time. Subsequent actions for this + * resource arrive via {@link onDidAction}. + */ + subscribe(resource: URI): Promise; + + /** Unsubscribe from state updates for the given URI. */ + unsubscribe(resource: URI): void; + + /** + * Fires when the server applies an action to subscribable state. + * Clients use this alongside {@link subscribe} to keep their local + * state in sync. + */ + readonly onDidAction: Event; + + /** + * Fires when the server broadcasts an ephemeral notification + * (e.g. sessionAdded, sessionRemoved). + */ + readonly onDidNotification: Event; + + /** + * Dispatch a client-originated action to the server. The server applies + * it to state, triggers side effects, and echoes it back via + * {@link onDidAction} with the client's origin for reconciliation. + */ + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void; + + /** + * List the contents of a directory on the agent host's filesystem. + * Used by the client to drive a remote folder picker before session creation. + */ + browseDirectory(uri: URI): Promise; + + /** + * Fetch stored content by URI from the agent host (e.g. file edit snapshots, + * or reading files from the remote filesystem). + */ + fetchContent(uri: URI): Promise; +} + +/** + * A concrete connection to an agent host - local utility process or remote + * WebSocket. Extends the core protocol surface with a `clientId` used for + * write-ahead reconciliation. Both {@link IAgentHostService} (local) and + * per-connection objects from {@link IRemoteAgentHostService} (remote) + * satisfy this contract. + */ +export interface IAgentConnection extends IAgentService { + /** Unique identifier for this client connection, used as the origin in action envelopes. */ + readonly clientId: string; +} + +export const IAgentHostService = createDecorator('agentHostService'); + +/** + * The local wrapper around the agent host process (manages lifecycle, restart, + * exposes the proxied service). Consumed by the main process and workbench. + */ +export interface IAgentHostService extends IAgentConnection { + + readonly onAgentHostExit: Event; + readonly onAgentHostStart: Event; + + restartAgentHost(): Promise; +} diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts new file mode 100644 index 0000000000000..33a7f544e8a13 --- /dev/null +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { connectionTokenQueryName } from '../../../base/common/network.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { IAgentConnection } from './agentService.js'; + +/** Configuration key for the list of remote agent host addresses. */ +export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts'; + +/** Configuration key to enable remote agent host connections. */ +export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled'; + +/** An entry in the {@link RemoteAgentHostsSettingId} setting. */ +export interface IRemoteAgentHostEntry { + readonly address: string; + readonly name: string; + readonly connectionToken?: string; +} + +export const enum RemoteAgentHostInputValidationError { + Empty = 'empty', + Invalid = 'invalid', +} + +export interface IParsedRemoteAgentHostInput { + readonly address: string; + readonly connectionToken?: string; + readonly suggestedName: string; +} + +export type RemoteAgentHostInputParseResult = + | { readonly parsed: IParsedRemoteAgentHostInput; readonly error?: undefined } + | { readonly parsed?: undefined; readonly error: RemoteAgentHostInputValidationError }; + +export const IRemoteAgentHostService = createDecorator('remoteAgentHostService'); + +/** + * Manages connections to one or more remote agent host processes over + * WebSocket. Each connection is identified by its address string and + * exposed as an {@link IAgentConnection}, the same interface used for + * the local agent host. + */ +export interface IRemoteAgentHostService { + readonly _serviceBrand: undefined; + + /** Fires when a remote connection is established or lost. */ + readonly onDidChangeConnections: Event; + + /** Currently connected remote addresses with metadata. */ + readonly connections: readonly IRemoteAgentHostConnectionInfo[]; + + /** All configured remote agent host entries from settings, regardless of connection status. */ + readonly configuredEntries: readonly IRemoteAgentHostEntry[]; + + /** + * Get a per-connection {@link IAgentConnection} for subscribing to + * state, dispatching actions, creating sessions, etc. + * + * Returns `undefined` if no active connection exists for the address. + */ + getConnection(address: string): IAgentConnection | undefined; + + /** + * Adds or updates a configured remote host and resolves once a connection + * to that host is available. + */ + addRemoteAgentHost(entry: IRemoteAgentHostEntry): Promise; + + /** + * Removes a configured remote host entry by address. + * Disconnects any active connection and removes the entry from settings. + */ + removeRemoteAgentHost(address: string): Promise; +} + +/** Metadata about a single remote connection. */ +export interface IRemoteAgentHostConnectionInfo { + readonly address: string; + readonly name: string; + readonly clientId: string; + readonly defaultDirectory?: string; +} + +export class NullRemoteAgentHostService implements IRemoteAgentHostService { + declare readonly _serviceBrand: undefined; + readonly onDidChangeConnections = Event.None; + readonly connections: readonly IRemoteAgentHostConnectionInfo[] = []; + readonly configuredEntries: readonly IRemoteAgentHostEntry[] = []; + getConnection(): IAgentConnection | undefined { return undefined; } + async addRemoteAgentHost(): Promise { + throw new Error('Remote agent host connections are not supported in this environment.'); + } + async removeRemoteAgentHost(_address: string): Promise { } +} + +export function parseRemoteAgentHostInput(input: string): RemoteAgentHostInputParseResult { + const trimmedInput = input.trim(); + if (!trimmedInput) { + return { error: RemoteAgentHostInputValidationError.Empty }; + } + + const candidate = extractRemoteAgentHostCandidate(trimmedInput); + if (!candidate) { + return { error: RemoteAgentHostInputValidationError.Invalid }; + } + + const hasExplicitScheme = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(candidate); + try { + const url = new URL(hasExplicitScheme ? candidate : `ws://${candidate}`); + const normalizedProtocol = normalizeRemoteAgentHostProtocol(url.protocol); + if (!normalizedProtocol || !url.host) { + return { error: RemoteAgentHostInputValidationError.Invalid }; + } + + const connectionToken = url.searchParams.get(connectionTokenQueryName) ?? undefined; + url.searchParams.delete(connectionTokenQueryName); + + // Only preserve wss: in the address - the transport defaults to ws: + const address = formatRemoteAgentHostAddress(url, normalizedProtocol === 'wss:' ? normalizedProtocol : undefined); + if (!address) { + return { error: RemoteAgentHostInputValidationError.Invalid }; + } + + return { + parsed: { + address, + connectionToken, + suggestedName: url.host, + }, + }; + } catch { + return { error: RemoteAgentHostInputValidationError.Invalid }; + } +} + +function extractRemoteAgentHostCandidate(input: string): string | undefined { + const urlMatch = input.match(/(?(?:https?|wss?):\/\/\S+)/i); + const candidate = urlMatch?.groups?.url ?? input; + const trimmedCandidate = candidate.trim().replace(/[),.;\]]+$/, ''); + return trimmedCandidate || undefined; +} + +function normalizeRemoteAgentHostProtocol(protocol: string): 'ws:' | 'wss:' | undefined { + switch (protocol.toLowerCase()) { + case 'ws:': + case 'http:': + return 'ws:'; + case 'wss:': + case 'https:': + return 'wss:'; + default: + return undefined; + } +} + +function formatRemoteAgentHostAddress(url: URL, protocol: 'ws:' | 'wss:' | undefined): string | undefined { + if (!url.host) { + return undefined; + } + + const path = url.pathname !== '/' ? url.pathname : ''; + const query = url.search; + const base = protocol ? `${protocol}//${url.host}` : url.host; + return `${base}${path}${query}`; +} diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts new file mode 100644 index 0000000000000..dcc52fde5565a --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ISessionDataService = createDecorator('sessionDataService'); + +/** + * Provides persistent, per-session data directories on disk. + * + * Each session gets a directory under `{userDataPath}/agentSessionData/{sessionId}/` + * where internal agent-host code can store arbitrary files (e.g. file snapshots). + * + * Directories are created lazily — callers should use {@link IFileService.createFolder} + * before writing files. Cleanup happens eagerly on session removal and via startup + * garbage collection for orphaned directories. + */ +export interface ISessionDataService { + readonly _serviceBrand: undefined; + + /** + * Returns the root data directory URI for a session. + * Does **not** create the directory on disk; callers use + * `IFileService.createFolder()` as needed. + */ + getSessionDataDir(session: URI): URI; + + /** + * Returns the root data directory URI for a session given its raw ID. + * Equivalent to {@link getSessionDataDir} but without requiring a full URI. + */ + getSessionDataDirById(sessionId: string): URI; + + /** + * Recursively deletes the data directory for a session, if it exists. + */ + deleteSessionData(session: URI): Promise; + + /** + * Deletes data directories that do not correspond to any known session. + * Called at startup; safe to call multiple times. + */ + cleanupOrphanedData(knownSessionIds: Set): Promise; +} diff --git a/src/vs/platform/agentHost/common/state/AGENTS.md b/src/vs/platform/agentHost/common/state/AGENTS.md new file mode 100644 index 0000000000000..25db0bd998fc1 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/AGENTS.md @@ -0,0 +1,81 @@ +# Protocol versioning instructions + +This directory contains the protocol version system. Read this before modifying any protocol types. + +## Overview + +The protocol has **living types** (in `sessionState.ts`, `sessionActions.ts`) and **version type snapshots** (in `versions/v1.ts`, etc.). The `versions/versionRegistry.ts` file contains compile-time checks that enforce backwards compatibility between them, plus a runtime map that tracks which action types belong to which version. + +The latest version file is the **tip** — it can be edited. Older version files are frozen. + +## Adding optional fields to existing types + +This is the most common change. No version bump needed. + +1. Add the optional field to the living type in `sessionState.ts` or `sessionActions.ts`: + ```typescript + export interface IToolCallState { + // ...existing fields... + readonly mcpServerName?: string; // new optional field + } + ``` +2. Add the same optional field to the corresponding type in the **tip** version file (currently `versions/v1.ts`): + ```typescript + export interface IV1_ToolCallState { + // ...existing fields... + readonly mcpServerName?: string; + } + ``` +3. Compile. If it passes, you're done. If it fails, you tried to do something incompatible. + +You can also skip step 2 — the tip is allowed to be a subset of the living type. But adding it to the tip documents that the field exists at this version. + +## Adding new action types + +Adding a new action type is backwards-compatible and does **not** require a version bump. Old clients at the same version ignore unknown action types (reducers return state unchanged). Old servers at the same version simply never produce the action. + +1. **Add the new action interface** to `sessionActions.ts` and include it in the `ISessionAction` or `IRootAction` union. +2. **Add the action to `ACTION_INTRODUCED_IN`** in `versions/versionRegistry.ts` with the **current** version number. The compiler will force you to do this — if you add a type to the union without a map entry, it won't compile. +3. **Add the type to the tip version file** (currently `versions/v1.ts`) and add an `AssertCompatible` check in `versions/versionRegistry.ts`. +4. **Add a reducer case** in `sessionReducers.ts` to handle the new action. +5. **Update `../../../protocol.md`** to document the new action. + +### When to bump the version + +Bump `PROTOCOL_VERSION` when you need a **capability boundary** — i.e., a client needs to check "does this server support feature X?" before sending commands or rendering UI. Examples: + +- A new **client-sendable** action that requires server-side support (the client must know the server can handle it before sending) +- A group of related actions that form a new feature area (subagents, model selection, etc.) + +When bumping: +1. **Bump `PROTOCOL_VERSION`** in `versions/versionRegistry.ts`. +2. **Create the new tip version file** `versions/v{N}.ts`. Copy the previous tip and add your new types. The previous tip is now frozen — do not edit it. +3. **Add `AssertCompatible` checks** in `versions/versionRegistry.ts` for the new version's types. +4. **Add `ProtocolCapabilities` fields** in `sessionCapabilities.ts` for the new feature area. +5. Assign your new action types version N in `ACTION_INTRODUCED_IN`. +6. **Update `../../../protocol.md`** version history. + +## Adding new notification types + +Same process as new action types, but use `NOTIFICATION_INTRODUCED_IN` instead of `ACTION_INTRODUCED_IN`. + +## Raising the minimum protocol version + +This drops support for old clients and lets you delete compatibility cruft. + +1. **Raise `MIN_PROTOCOL_VERSION`** in `versions/versionRegistry.ts` from N to N+1. +2. **Delete `versions/v{N}.ts`**. +3. **Remove the v{N} `AssertCompatible` checks** and version-grouped type aliases from `versions/versionRegistry.ts`. +4. **Compile.** The compiler will surface any code that referenced the deleted version types — clean it up. +5. **Update `../../../protocol.md`** version history. + +## What the compiler catches + +| Mistake | Compile error | +|---|---| +| Remove a field from a living type | `Current extends Frozen` fails in `AssertCompatible` | +| Change a field's type | `Current extends Frozen` fails in `AssertCompatible` | +| Add a required field to a living type | `Frozen extends Current` fails in `AssertCompatible` | +| Add action to union, forget `ACTION_INTRODUCED_IN` entry | Mapped type index is incomplete | +| Add notification to union, forget `NOTIFICATION_INTRODUCED_IN` entry | Mapped type index is incomplete | +| Remove action type that a version still references | Version-grouped union no longer extends living union | diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts new file mode 100644 index 0000000000000..307e98daa2a32 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +// Generated from types/actions.ts — do not edit +// Run `npm run generate` to regenerate. + +import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction } from './actions.js'; + + +// ─── Root vs Session Action Unions ─────────────────────────────────────────── + +/** Union of all root-scoped actions. */ +export type IRootAction = + | IRootAgentsChangedAction + | IRootActiveSessionsChangedAction + ; + +/** Union of all session-scoped actions. */ +export type ISessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionTurnStartedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionTurnCompleteAction + | ISessionTurnCancelledAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionModelChangedAction + | ISessionServerToolsChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that clients may dispatch. */ +export type IClientSessionAction = + | ISessionTurnStartedAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionTurnCancelledAction + | ISessionModelChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that only the server may produce. */ +export type IServerSessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionTurnCompleteAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionServerToolsChangedAction + ; + +// ─── Client-Dispatchable Map ───────────────────────────────────────────────── + +/** + * Exhaustive map indicating which action types may be dispatched by clients. + * Adding a new action to IStateAction without adding it here is a compile error. + */ +export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = { + [ActionType.RootAgentsChanged]: false, + [ActionType.RootActiveSessionsChanged]: false, + [ActionType.SessionReady]: false, + [ActionType.SessionCreationFailed]: false, + [ActionType.SessionTurnStarted]: true, + [ActionType.SessionDelta]: false, + [ActionType.SessionResponsePart]: false, + [ActionType.SessionToolCallStart]: false, + [ActionType.SessionToolCallDelta]: false, + [ActionType.SessionToolCallReady]: false, + [ActionType.SessionToolCallConfirmed]: true, + [ActionType.SessionToolCallComplete]: true, + [ActionType.SessionToolCallResultConfirmed]: true, + [ActionType.SessionTurnComplete]: false, + [ActionType.SessionTurnCancelled]: true, + [ActionType.SessionError]: false, + [ActionType.SessionTitleChanged]: false, + [ActionType.SessionUsage]: false, + [ActionType.SessionReasoning]: false, + [ActionType.SessionModelChanged]: true, + [ActionType.SessionServerToolsChanged]: false, + [ActionType.SessionActiveClientChanged]: true, + [ActionType.SessionActiveClientToolsChanged]: true, +}; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts new file mode 100644 index 0000000000000..769b34629b177 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -0,0 +1,546 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo } from './state.js'; + + +// ─── Action Type Enum ──────────────────────────────────────────────────────── + +/** + * Discriminant values for all state actions. + * + * @category Actions + */ +export const enum ActionType { + RootAgentsChanged = 'root/agentsChanged', + RootActiveSessionsChanged = 'root/activeSessionsChanged', + SessionReady = 'session/ready', + SessionCreationFailed = 'session/creationFailed', + SessionTurnStarted = 'session/turnStarted', + SessionDelta = 'session/delta', + SessionResponsePart = 'session/responsePart', + SessionToolCallStart = 'session/toolCallStart', + SessionToolCallDelta = 'session/toolCallDelta', + SessionToolCallReady = 'session/toolCallReady', + SessionToolCallConfirmed = 'session/toolCallConfirmed', + SessionToolCallComplete = 'session/toolCallComplete', + SessionToolCallResultConfirmed = 'session/toolCallResultConfirmed', + SessionTurnComplete = 'session/turnComplete', + SessionTurnCancelled = 'session/turnCancelled', + SessionError = 'session/error', + SessionTitleChanged = 'session/titleChanged', + SessionUsage = 'session/usage', + SessionReasoning = 'session/reasoning', + SessionModelChanged = 'session/modelChanged', + SessionServerToolsChanged = 'session/serverToolsChanged', + SessionActiveClientChanged = 'session/activeClientChanged', + SessionActiveClientToolsChanged = 'session/activeClientToolsChanged', +} + +// ─── Action Envelope ───────────────────────────────────────────────────────── + +/** + * Identifies the client that originally dispatched an action. + */ +export interface IActionOrigin { + clientId: string; + clientSeq: number; +} + +/** + * Every action is wrapped in an `ActionEnvelope`. + */ +export interface IActionEnvelope { + readonly action: IStateAction; + readonly serverSeq: number; + readonly origin: IActionOrigin | undefined; + readonly rejectionReason?: string; +} + +// ─── Root Actions ──────────────────────────────────────────────────────────── + +/** + * Base interface for all tool-call-scoped actions, carrying the common + * session, turn, and tool call identifiers. + * + * @category Session Actions + */ +interface IToolCallActionBase { + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Tool call identifier */ + toolCallId: string; + /** + * Additional provider-specific metadata for this tool call. + * + * Clients MAY look for well-known keys here to provide enhanced UI. + * For example, a `ptyTerminal` key with `{ input: string; output: string }` + * indicates the tool operated on a terminal (both `input` and `output` may + * contain escape sequences). + */ + _meta?: Record; +} + +/** + * Fired when available agent backends or their models change. + * + * @category Root Actions + * @version 1 + */ +export interface IRootAgentsChangedAction { + type: ActionType.RootAgentsChanged; + /** Updated agent list */ + agents: IAgentInfo[]; +} + +/** + * Fired when the number of active sessions changes. + * + * @category Root Actions + * @version 1 + */ +export interface IRootActiveSessionsChangedAction { + type: ActionType.RootActiveSessionsChanged; + /** Current count of active sessions */ + activeSessions: number; +} + +// ─── Session Actions ───────────────────────────────────────────────────────── + +/** + * Session backend initialized successfully. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionReadyAction { + type: ActionType.SessionReady; + /** Session URI */ + session: URI; +} + +/** + * Session backend failed to initialize. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionCreationFailedAction { + type: ActionType.SessionCreationFailed; + /** Session URI */ + session: URI; + /** Error details */ + error: IErrorInfo; +} + +/** + * User sent a message; server starts agent processing. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionTurnStartedAction { + type: ActionType.SessionTurnStarted; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** User's message */ + userMessage: IUserMessage; +} + +/** + * Streaming text chunk from the assistant, appended to a specific response part. + * + * The server MUST first emit a `session/responsePart` to create the target + * part (markdown or reasoning), then use this action to append text to it. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionDeltaAction { + type: ActionType.SessionDelta; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Identifier of the response part to append to */ + partId: string; + /** Text chunk */ + content: string; +} + +/** + * Structured content appended to the response. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionResponsePartAction { + type: ActionType.SessionResponsePart; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Response part (markdown or content ref) */ + part: IResponsePart; +} + +/** + * A tool call begins — parameters are streaming from the LM. + * + * For client-provided tools, the server sets `toolClientId` to identify the + * owning client. That client is responsible for executing the tool once it + * reaches the `running` state and dispatching `session/toolCallComplete`. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionToolCallStartAction extends IToolCallActionBase { + type: ActionType.SessionToolCallStart; + /** Internal tool name (for debugging/logging) */ + toolName: string; + /** Human-readable tool name */ + displayName: string; + /** + * If this tool is provided by a client, the `clientId` of the owning client. + * Absent for server-side tools. + */ + toolClientId?: string; +} + +/** + * Streaming partial parameters for a tool call. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionToolCallDeltaAction extends IToolCallActionBase { + type: ActionType.SessionToolCallDelta; + /** Partial parameter content to append */ + content: string; + /** Updated progress message */ + invocationMessage?: StringOrMarkdown; +} + +/** + * Tool call parameters are complete, or a running tool requires re-confirmation. + * + * When dispatched for a `streaming` tool call, transitions to `pending-confirmation` + * or directly to `running` if `confirmed` is set. + * + * When dispatched for a `running` tool call (e.g. mid-execution permission needed), + * transitions back to `pending-confirmation`. The `invocationMessage` and `_meta` + * SHOULD be updated to describe the specific confirmation needed. Clients use the + * standard `session/toolCallConfirmed` flow to approve or deny. + * + * For client-provided tools, the server typically sets `confirmed` to + * `'not-needed'` so the tool transitions directly to `running`, where the + * owning client can begin execution immediately. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionToolCallReadyAction extends IToolCallActionBase { + type: ActionType.SessionToolCallReady; + /** Message describing what the tool will do or what confirmation is needed */ + invocationMessage: StringOrMarkdown; + /** Raw tool input */ + toolInput?: string; + /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ + confirmationTitle?: StringOrMarkdown; + /** If set, the tool was auto-confirmed and transitions directly to `running` */ + confirmed?: ToolCallConfirmationReason; +} + +/** + * Client approves a pending tool call. The tool transitions to `running`. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionToolCallApprovedAction extends IToolCallActionBase { + type: ActionType.SessionToolCallConfirmed; + /** The tool call was approved */ + approved: true; + /** How the tool was confirmed */ + confirmed: ToolCallConfirmationReason; +} + +/** + * Client denies a pending tool call. The tool transitions to `cancelled`. + * + * For client-provided tools, the owning client MUST dispatch this if it does + * not recognize the tool or cannot execute it. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionToolCallDeniedAction extends IToolCallActionBase { + type: ActionType.SessionToolCallConfirmed; + /** The tool call was denied */ + approved: false; + /** Why the tool was cancelled */ + reason: ToolCallCancellationReason.Denied | ToolCallCancellationReason.Skipped; + /** What the user suggested doing instead */ + userSuggestion?: IUserMessage; + /** Optional explanation for the denial */ + reasonMessage?: StringOrMarkdown; +} + +/** + * Client confirms or denies a pending tool call. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export type ISessionToolCallConfirmedAction = + | ISessionToolCallApprovedAction + | ISessionToolCallDeniedAction; + +/** + * Tool execution finished. Transitions to `completed` or `pending-result-confirmation` + * if `requiresResultConfirmation` is `true`. + * + * For client-provided tools (where `toolClientId` is set on the tool call state), + * the owning client dispatches this action with the execution result. The server + * SHOULD reject this action if the dispatching client does not match `toolClientId`. + * + * Servers waiting on a client tool call MAY time out after a reasonable duration + * if the implementing client disconnects or becomes unresponsive, and dispatch + * this action with `result.success = false` and an appropriate error. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionToolCallCompleteAction extends IToolCallActionBase { + type: ActionType.SessionToolCallComplete; + /** Execution result */ + result: IToolCallResult; + /** If true, the result requires client approval before finalizing */ + requiresResultConfirmation?: boolean; +} + +/** + * Client approves or denies a tool's result. + * + * If `approved` is `false`, the tool transitions to `cancelled` with reason `result-denied`. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBase { + type: ActionType.SessionToolCallResultConfirmed; + /** Whether the result was approved */ + approved: boolean; +} + +/** + * Turn finished — the assistant is idle. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionTurnCompleteAction { + type: ActionType.SessionTurnComplete; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; +} + +/** + * Turn was aborted; server stops processing. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionTurnCancelledAction { + type: ActionType.SessionTurnCancelled; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; +} + +/** + * Error during turn processing. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionErrorAction { + type: ActionType.SessionError; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Error details */ + error: IErrorInfo; +} + +/** + * Session title updated (typically auto-generated from conversation). + * + * @category Session Actions + * @version 1 + */ +export interface ISessionTitleChangedAction { + type: ActionType.SessionTitleChanged; + /** Session URI */ + session: URI; + /** New title */ + title: string; +} + +/** + * Token usage report for a turn. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionUsageAction { + type: ActionType.SessionUsage; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Token usage data */ + usage: IUsageInfo; +} + +/** + * Reasoning/thinking text from the model, appended to a specific reasoning response part. + * + * The server MUST first emit a `session/responsePart` to create the target + * reasoning part, then use this action to append text to it. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionReasoningAction { + type: ActionType.SessionReasoning; + /** Session URI */ + session: URI; + /** Turn identifier */ + turnId: string; + /** Identifier of the reasoning response part to append to */ + partId: string; + /** Reasoning text chunk */ + content: string; +} + +/** + * Model changed for this session. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionModelChangedAction { + type: ActionType.SessionModelChanged; + /** Session URI */ + session: URI; + /** New model ID */ + model: string; +} + +/** + * Server tools for this session have changed. + * + * Full-replacement semantics: the `tools` array replaces the previous `serverTools` entirely. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionServerToolsChangedAction { + type: ActionType.SessionServerToolsChanged; + /** Session URI */ + session: URI; + /** Updated server tools list (full replacement) */ + tools: IToolDefinition[]; +} + +/** + * The active client for this session has changed. + * + * A client dispatches this action with its own `ISessionActiveClient` to claim + * the active role, or with `null` to release it. The server SHOULD reject if + * another client is already active. The server SHOULD automatically dispatch + * this action with `activeClient: null` when the active client disconnects. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionActiveClientChangedAction { + type: ActionType.SessionActiveClientChanged; + /** Session URI */ + session: URI; + /** The new active client, or `null` to unset */ + activeClient: ISessionActiveClient | null; +} + +/** + * The active client's tool list has changed. + * + * Full-replacement semantics: the `tools` array replaces the active client's + * previous tools entirely. The server SHOULD reject if the dispatching client + * is not the current active client. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionActiveClientToolsChangedAction { + type: ActionType.SessionActiveClientToolsChanged; + /** Session URI */ + session: URI; + /** Updated client tools list (full replacement) */ + tools: IToolDefinition[]; +} + +// ─── Discriminated Union ───────────────────────────────────────────────────── + +/** + * Discriminated union of all state actions. + */ +export type IStateAction = + | IRootAgentsChangedAction + | IRootActiveSessionsChangedAction + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionTurnStartedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionTurnCompleteAction + | ISessionTurnCancelledAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionModelChangedAction + | ISessionServerToolsChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts new file mode 100644 index 0000000000000..ba6ef070667c2 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -0,0 +1,490 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; +import type { IActionEnvelope, IStateAction } from './actions.js'; + +// ─── initialize ────────────────────────────────────────────────────────────── + +/** + * Establishes a new connection and negotiates the protocol version. + * This MUST be the first message sent by the client. + * + * @category Commands + * @method initialize + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/lifecycle | Lifecycle} for the full handshake flow. + */ +export interface IInitializeParams { + /** Protocol version the client speaks */ + protocolVersion: number; + /** Unique client identifier */ + clientId: string; + /** URIs to subscribe to during handshake */ + initialSubscriptions?: URI[]; +} + +/** + * Result of the `initialize` command. + * + * If the server does not support the client's protocol version, it MUST return + * error code `-32005` (`UnsupportedProtocolVersion`). + */ +export interface IInitializeResult { + /** Protocol version the server speaks */ + protocolVersion: number; + /** Current server sequence number */ + serverSeq: number; + /** Snapshots for each `initialSubscriptions` URI */ + snapshots: ISnapshot[]; + /** Suggested default directory for remote filesystem browsing */ + defaultDirectory?: URI; +} + +// ─── reconnect ─────────────────────────────────────────────────────────────── + +/** + * Discriminant for reconnect result types. + * + * @category Commands + */ +export const enum ReconnectResultType { + Replay = 'replay', + Snapshot = 'snapshot', +} + +/** + * Re-establishes a dropped connection. The server replays missed actions or + * provides fresh snapshots. + * + * @category Commands + * @method reconnect + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/lifecycle | Lifecycle} for details. + */ +export interface IReconnectParams { + /** Client identifier from the original connection */ + clientId: string; + /** Last `serverSeq` the client received */ + lastSeenServerSeq: number; + /** URIs the client was subscribed to */ + subscriptions: URI[]; +} + +/** + * Reconnect result when the server can replay from the requested sequence. + * + * The server MUST include all replayed data in the response. + */ +export interface IReconnectReplayResult { + /** Discriminant */ + type: ReconnectResultType.Replay; + /** Missed action envelopes since `lastSeenServerSeq` */ + actions: IActionEnvelope[]; +} + +/** + * Reconnect result when the gap exceeds the replay buffer. + */ +export interface IReconnectSnapshotResult { + /** Discriminant */ + type: ReconnectResultType.Snapshot; + /** Fresh snapshots for each subscription */ + snapshots: ISnapshot[]; +} + +/** Result of the `reconnect` command. */ +export type IReconnectResult = IReconnectReplayResult | IReconnectSnapshotResult; + +// ─── subscribe ─────────────────────────────────────────────────────────────── + +/** + * Subscribe to a URI-identified state resource. + * + * @category Commands + * @method subscribe + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/subscriptions | Subscriptions} + */ +export interface ISubscribeParams { + /** URI to subscribe to */ + resource: URI; +} + +/** + * Result of the `subscribe` command. + */ +export interface ISubscribeResult { + /** Snapshot of the subscribed resource */ + snapshot: ISnapshot; +} + +// ─── createSession ─────────────────────────────────────────────────────────── + +/** + * Creates a new session with the specified agent provider. + * + * If the session URI already exists, the server MUST return an error with code + * `-32003` (`SessionAlreadyExists`). + * + * After creation, the client should subscribe to the session URI to receive state + * updates. The server also broadcasts a `notify/sessionAdded` notification to all + * clients. + * + * @category Commands + * @method createSession + * @direction Client → Server + * @messageType Request + * @version 1 + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 2, "method": "createSession", + * "params": { "session": "copilot:/", "provider": "copilot", "model": "gpt-4o" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 2, "result": null } + * + * // Server → Client (failure — provider not found) + * { "jsonrpc": "2.0", "id": 2, "error": { "code": -32002, "message": "No agent for provider" } } + * + * // Server → Client (failure — session already exists) + * { "jsonrpc": "2.0", "id": 2, "error": { "code": -32003, "message": "Session already exists" } } + * ``` + */ +export interface ICreateSessionParams { + /** Session URI (client-chosen, e.g. `copilot:/`) */ + session: URI; + /** Agent provider ID */ + provider?: string; + /** Model ID to use */ + model?: string; + /** Working directory for the session */ + workingDirectory?: URI; +} + +// ─── disposeSession ────────────────────────────────────────────────────────── + +/** + * Disposes a session and cleans up server-side resources. + * + * The server broadcasts a `notify/sessionRemoved` notification to all clients. + * + * @category Commands + * @method disposeSession + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface IDisposeSessionParams { + /** Session URI to dispose */ + session: URI; +} + +// ─── listSessions ──────────────────────────────────────────────────────────── + +/** + * Returns a list of session summaries. Used to populate session lists and sidebars. + * + * The session list is **not** part of the state tree because it can be arbitrarily + * large. Clients fetch it imperatively and maintain a local cache updated by + * `notify/sessionAdded` and `notify/sessionRemoved` notifications. + * + * @category Commands + * @method listSessions + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface IListSessionsParams { + /** Optional filter criteria */ + filter?: object; +} + +/** Result of the `listSessions` command. */ +export interface IListSessionsResult { + /** The list of session summaries. */ + items: ISessionSummary[]; +} + +// ─── fetchContent ──────────────────────────────────────────────────────────── + +/** + * Encoding of fetched content data. + * + * @category Commands + */ +export const enum ContentEncoding { + Base64 = 'base64', + Utf8 = 'utf-8', +} + +/** + * Fetches large content referenced by a `ContentRef` in the state tree. + * + * Content references keep the state tree small by storing large data (images, + * long tool outputs) by reference rather than inline. + * + * Binary content (images, etc.) MUST use `base64` encoding. Text content MAY + * use `utf-8` encoding. + * + * @category Commands + * @method fetchContent + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the URI does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the URI. + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 10, "method": "fetchContent", + * "params": { "uri": "copilot://content/img-1" } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 10, "result": { + * "data": "iVBORw0KGgo...", + * "encoding": "base64", + * "contentType": "image/png" + * }} + * ``` + */ +export interface IFetchContentParams { + /** Content URI from a `ContentRef` */ + uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; +} + +/** + * Result of the `fetchContent` command. + * + * The server SHOULD honor the `encoding` requested in the params. If the + * server cannot provide the requested encoding, it MUST fall back to either + * `base64` or `utf-8`. + */ +export interface IFetchContentResult { + /** Content encoded as a string */ + data: string; + /** How `data` is encoded */ + encoding: ContentEncoding; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; +} + +// ─── browseDirectory ──────────────────────────────────────────────────────── + +/** + * Lists directory entries at a file URI on the server's filesystem. + * + * This is intended for remote folder pickers and similar UI that needs to let + * users navigate the server's local filesystem. + * + * The server MUST return success only if the target exists and is a directory. + * If the target does not exist, is not a directory, or cannot be accessed, the + * server MUST return a JSON-RPC error. + * + * @category Commands + * @method browseDirectory + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the directory does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to browse the directory. + */ +export interface IBrowseDirectoryParams { + /** Directory URI on the server filesystem */ + uri: URI; +} + +/** + * Directory entry returned by `browseDirectory`. + */ +export interface IDirectoryEntry { + /** Base name of the entry */ + name: string; + /** Whether the entry is a file or directory */ + type: 'file' | 'directory'; +} + +/** + * Result of the `browseDirectory` command. + */ +export interface IBrowseDirectoryResult { + /** Entries directly contained in the requested directory */ + entries: IDirectoryEntry[]; +} + +// ─── fetchTurns ────────────────────────────────────────────────────────────── + +/** + * Fetches historical turns for a session. Used for lazy loading of conversation + * history. + * + * @category Commands + * @method fetchTurns + * @direction Client → Server + * @messageType Request + * @version 1 + * @example + * ```jsonc + * // Client → Server (fetch the 20 most recent turns) + * { "jsonrpc": "2.0", "id": 8, "method": "fetchTurns", + * "params": { "session": "copilot:/", "limit": 20 } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 8, "result": { + * "turns": [ { "id": "t1", ... }, { "id": "t2", ... } ], + * "hasMore": true + * }} + * + * // Client → Server (fetch 20 turns before t1) + * { "jsonrpc": "2.0", "id": 9, "method": "fetchTurns", + * "params": { "session": "copilot:/", "before": "t1", "limit": 20 } } + * ``` + */ +export interface IFetchTurnsParams { + /** Session URI */ + session: URI; + /** Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. */ + before?: string; + /** Maximum number of turns to return. Server MAY impose its own upper bound. */ + limit?: number; +} + +/** + * Result of the `fetchTurns` command. + */ +export interface IFetchTurnsResult { + /** The requested turns, ordered oldest-first */ + turns: ITurn[]; + /** Whether more turns exist before the returned range */ + hasMore: boolean; +} + +// ─── unsubscribe ───────────────────────────────────────────────────────────── + +/** + * Stop receiving updates for a URI. + * + * @category Commands + * @method unsubscribe + * @direction Client → Server + * @messageType Notification + * @version 1 + * @see {@link /specification/subscriptions | Subscriptions} + */ +export interface IUnsubscribeParams { + /** URI to unsubscribe from */ + resource: URI; +} + +// ─── dispatchAction ────────────────────────────────────────────────────────── + +/** + * Fire-and-forget action dispatch (write-ahead). The client applies actions + * optimistically to local state. + * + * @category Commands + * @method dispatchAction + * @direction Client → Server + * @messageType Notification + * @version 1 + * @see {@link /guide/actions | Actions} for the full list of client-dispatchable actions. + */ +export interface IDispatchActionParams { + /** Client sequence number */ + clientSeq: number; + /** The action to dispatch */ + action: IStateAction; +} + +// ─── browseDirectory ──────────────────────────────────────────────────── + +/** + * Lists the contents of a directory on the server. Used by clients to + * present directory pickers or file browsers. + * + * @category Commands + * @method browseDirectory + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface IBrowseDirectoryParams { + /** Directory path to browse. Omit to list the default/root directory. */ + directory?: string; +} + +/** + * A single entry in a directory listing. + */ +export interface IBrowseDirectoryEntry { + /** Entry name (not a full path) */ + name: string; + /** Whether this entry is a directory */ + isDirectory: boolean; +} + +// ─── authenticate ──────────────────────────────────────────────────────────── + +/** + * Pushes a Bearer token for a protected resource. The `resource` field MUST + * match an `IProtectedResourceMetadata.resource` value declared by an agent + * in `IAgentInfo.protectedResources`. + * + * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) + * (Bearer Token Usage) semantics. The client obtains the token from the + * authorization server(s) listed in the resource's metadata and pushes it + * to the server via this command. + * + * @category Commands + * @method authenticate + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", + * "params": { "resource": "https://api.github.com", "token": "gho_xxxx" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 3, "result": {} } + * + * // Server → Client (failure — invalid token) + * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } + * ``` + */ +export interface IAuthenticateParams { + /** + * The protected resource identifier. MUST match a `resource` value from + * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + */ + resource: string; + /** Bearer token obtained from the resource's authorization server */ + token: string; +} + +/** + * Result of the `authenticate` command. + * + * An empty object on success. If the token is invalid or the resource is + * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` + * `-32007` or `InvalidParams` `-32602`). + */ +export interface IAuthenticateResult { +} diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts new file mode 100644 index 0000000000000..e33c52c234320 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +// ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── + +/** + * Standard JSON-RPC 2.0 error codes. + * + * @category Standard JSON-RPC Codes + */ +export const JsonRpcErrorCodes = { + /** Invalid JSON */ + ParseError: -32700, + /** Not a valid JSON-RPC request */ + InvalidRequest: -32600, + /** Unknown method name */ + MethodNotFound: -32601, + /** Invalid method parameters */ + InvalidParams: -32602, + /** Unspecified server error */ + InternalError: -32603, +} as const; + +// ─── AHP Application Codes ────────────────────────────────────────────────── + +/** + * AHP application-specific error codes. + * + * @category AHP Application Codes + * @version 1 + */ +export const AhpErrorCodes = { + /** The referenced session URI does not exist */ + SessionNotFound: -32001, + /** The requested agent provider is not registered */ + ProviderNotFound: -32002, + /** A session with the given URI already exists */ + SessionAlreadyExists: -32003, + /** The operation requires no active turn, but one is in progress */ + TurnInProgress: -32004, + /** The client's protocol version is not supported by the server */ + UnsupportedProtocolVersion: -32005, + /** The requested content URI does not exist */ + ContentNotFound: -32006, + /** + * A command failed because the client has not authenticated for a required + * protected resource. The `data` field of the JSON-RPC error SHOULD contain + * an `IProtectedResourceMetadata[]` array describing the resources that + * require authentication. + * + * @see {@link /specification/authentication | Authentication} + */ + AuthRequired: -32007, + /** The requested file, folder, or URI does not exist */ + NotFound: -32008, + /** + * The client is not permitted to access the requested resource. + * + * Servers SHOULD return this when a client attempts to read or browse + * a path outside the allowed set (e.g. outside the session's working + * directory or workspace roots). + */ + PermissionDenied: -32009, +} as const; + +/** Union type of all AHP application error codes. */ +export type AhpErrorCode = (typeof AhpErrorCodes)[keyof typeof AhpErrorCodes]; + +/** Union type of all JSON-RPC error codes. */ +export type JsonRpcErrorCode = (typeof JsonRpcErrorCodes)[keyof typeof JsonRpcErrorCodes]; diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts new file mode 100644 index 0000000000000..1e053c2957961 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -0,0 +1,216 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; + +import type { IActionEnvelope } from './actions.js'; +import type { IProtocolNotification } from './notifications.js'; + +// ─── JSON-RPC Base Types ───────────────────────────────────────────────────── + +/** A JSON-RPC request: has both `method` and `id`. */ +export interface IJsonRpcRequest { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: string; + readonly params?: unknown; +} + +/** A JSON-RPC success response. */ +export interface IJsonRpcSuccessResponse { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly result: unknown; +} + +/** A JSON-RPC error response. */ +export interface IJsonRpcErrorResponse { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly error: { + readonly code: number; + readonly message: string; + readonly data?: unknown; + }; +} + +/** A JSON-RPC response (success or error). */ +export type IJsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; + +/** A JSON-RPC notification: has `method` but no `id`. */ +export interface IJsonRpcNotification { + readonly jsonrpc: '2.0'; + readonly method: string; + readonly params?: unknown; +} + +// ─── Command Map ───────────────────────────────────────────────────────────── + +/** + * Registry mapping each command method name to its params and result types. + * + * @category Commands + */ +export interface ICommandMap { + 'initialize': { params: IInitializeParams; result: IInitializeResult }; + 'reconnect': { params: IReconnectParams; result: IReconnectResult }; + 'subscribe': { params: ISubscribeParams; result: ISubscribeResult }; + 'createSession': { params: ICreateSessionParams; result: null }; + 'disposeSession': { params: IDisposeSessionParams; result: null }; + 'listSessions': { params: IListSessionsParams; result: IListSessionsResult }; + 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; + 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; + 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; + 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; +} + +// ─── Notification Maps ─────────────────────────────────────────────────────── + +/** Params for the server → client `notification` method. */ +export interface INotificationMethodParams { + notification: IProtocolNotification; +} + +/** + * Registry mapping each client → server notification method to its params type. + * + * @category Notifications + */ +export interface IClientNotificationMap { + 'unsubscribe': { params: IUnsubscribeParams }; + 'dispatchAction': { params: IDispatchActionParams }; +} + +/** + * Registry mapping each server → client notification method to its params type. + * + * @category Notifications + */ +export interface IServerNotificationMap { + 'action': { params: IActionEnvelope }; + 'notification': { params: INotificationMethodParams }; +} + +/** Combined notification map for all directions. */ +export type INotificationMap = IClientNotificationMap & IServerNotificationMap; + +// ─── Typed Requests ────────────────────────────────────────────────────────── + +/** + * A fully typed JSON-RPC request for a specific AHP command. + * + * When used as a union (default generic), narrowing on `method` gives typed `params`: + * + * ```ts + * function handle(req: IAhpRequest) { + * if (req.method === 'fetchTurns') { + * req.params.session; // typed as URI + * } + * } + * ``` + */ +export type IAhpRequest = + M extends unknown ? { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly method: M; + readonly params: ICommandMap[M]['params']; + } : never; + +// ─── Typed Responses ───────────────────────────────────────────────────────── + +/** + * A fully typed JSON-RPC success response for a specific AHP command. + * + * Since JSON-RPC responses do not carry `method`, use this with an explicit + * generic parameter when you know the method from the associated request: + * + * ```ts + * const result: IAhpSuccessResponse<'fetchTurns'> = ...; + * result.result.turns; // typed as ITurn[] + * ``` + */ +export type IAhpSuccessResponse = + M extends unknown ? { + readonly jsonrpc: '2.0'; + readonly id: number; + readonly result: ICommandMap[M]['result']; + } : never; + +/** Typed JSON-RPC response (success with known result type, or error). */ +export type IAhpResponse = + | IAhpSuccessResponse + | IJsonRpcErrorResponse; + +// ─── Typed Notifications ───────────────────────────────────────────────────── + +/** + * A fully typed JSON-RPC notification for a specific AHP notification method. + * + * When used as a union (default generic), narrowing on `method` gives typed `params`: + * + * ```ts + * function handle(notif: IAhpNotification) { + * if (notif.method === 'action') { + * notif.params.serverSeq; // typed as number + * } + * } + * ``` + */ +export type IAhpNotification = + M extends unknown ? { + readonly jsonrpc: '2.0'; + readonly method: M; + readonly params: INotificationMap[M]['params']; + } : never; + +/** A client → server notification. */ +export type IAhpClientNotification = + M extends unknown ? { + readonly jsonrpc: '2.0'; + readonly method: M; + readonly params: IClientNotificationMap[M]['params']; + } : never; + +/** A server → client notification. */ +export type IAhpServerNotification = + M extends unknown ? { + readonly jsonrpc: '2.0'; + readonly method: M; + readonly params: IServerNotificationMap[M]['params']; + } : never; + +// ─── Protocol Message Union ────────────────────────────────────────────────── + +/** + * Discriminated union of all AHP protocol messages. + * + * Narrow using standard JSON-RPC structure: + * - Has `method` + `id` → request ({@link IAhpRequest}) + * - Has `method`, no `id` → notification ({@link IAhpNotification}) + * - Has `result` or `error` + `id` → response ({@link IAhpResponse}) + * + * Then narrow on `method` for fully typed params: + * + * ```ts + * function dispatch(msg: IProtocolMessage) { + * if ('method' in msg && 'id' in msg) { + * // msg is IAhpRequest + * if (msg.method === 'fetchTurns') { + * msg.params.session; // URI + * } + * } + * } + * ``` + */ +export type IProtocolMessage = + | IAhpRequest + | IAhpSuccessResponse + | IJsonRpcErrorResponse + | IAhpNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts new file mode 100644 index 0000000000000..ec2a1878c9f9a --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import type { URI, ISessionSummary } from './state.js'; + +/** + * Reason why authentication is required. + * + * @category Protocol Notifications + */ +export const enum AuthRequiredReason { + /** The client has not yet authenticated for the resource */ + Required = 'required', + /** A previously valid token has expired or been revoked */ + Expired = 'expired', +} + +// ─── Protocol Notifications ────────────────────────────────────────────────── + +/** + * Discriminant values for all protocol notifications. + * + * @category Protocol Notifications + */ +export const enum NotificationType { + SessionAdded = 'notify/sessionAdded', + SessionRemoved = 'notify/sessionRemoved', + AuthRequired = 'notify/authRequired', +} + +/** + * Broadcast to all connected clients when a new session is created. + * + * @category Protocol Notifications + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/sessionAdded", + * "summary": { + * "resource": "copilot:/", + * "provider": "copilot", + * "title": "New Session", + * "status": "idle", + * "createdAt": 1710000000000, + * "modifiedAt": 1710000000000 + * } + * } + * } + * } + * ``` + */ +export interface ISessionAddedNotification { + type: NotificationType.SessionAdded; + /** Summary of the new session */ + summary: ISessionSummary; +} + +/** + * Broadcast to all connected clients when a session is disposed. + * + * @category Protocol Notifications + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/sessionRemoved", + * "session": "copilot:/" + * } + * } + * } + * ``` + */ +export interface ISessionRemovedNotification { + type: NotificationType.SessionRemoved; + /** URI of the removed session */ + session: URI; +} + +/** + * Sent by the server when a protected resource requires (re-)authentication. + * + * This notification is sent when a previously valid token expires or is + * revoked, or when the server discovers a new authentication requirement. + * Clients should obtain a fresh token and push it via the `authenticate` + * command. + * + * @category Protocol Notifications + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/authRequired", + * "resource": "https://api.github.com", + * "reason": "expired" + * } + * } + * } + * ``` + */ +export interface IAuthRequiredNotification { + type: NotificationType.AuthRequired; + /** The protected resource identifier that requires authentication */ + resource: string; + /** Why authentication is required */ + reason?: AuthRequiredReason; +} + +/** + * Discriminated union of all protocol notifications. + */ +export type IProtocolNotification = + | ISessionAddedNotification + | ISessionRemovedNotification + | IAuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts new file mode 100644 index 0000000000000..581201add39e9 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -0,0 +1,472 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import { ActionType } from './actions.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, type IRootState, type ISessionState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn } from './state.js'; +import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction } from './action-origin.generated.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Soft assertion for exhaustiveness checking. Place in the `default` branch of + * a switch on a discriminated union so the compiler errors when a new variant + * is added but not handled. + * + * At runtime, logs a warning instead of throwing so that forward-compatible + * clients receiving unknown actions from a newer server degrade gracefully. + */ +export function softAssertNever(value: never, log?: (msg: string) => void): void { + const msg = `Unhandled action type: ${(value as { type: string }).type}`; + (log ?? console.warn)(msg); +} + +/** Extracts the common base fields shared by all tool call lifecycle states. */ +function tcBase(tc: IToolCallState) { + return { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolClientId: tc.toolClientId, + _meta: tc._meta, + }; +} + +/** + * Ends the active turn, finalizing it into a completed turn record. + * + * Tool call parts with non-terminal states are forced to cancelled. + * Pending permissions are stripped from tool call parts. + */ +function endTurn( + state: ISessionState, + turnId: string, + turnState: TurnState, + summaryStatus: SessionStatus, + error?: { errorType: string; message: string; stack?: string }, +): ISessionState { + if (!state.activeTurn || state.activeTurn.id !== turnId) { + return state; + } + const active = state.activeTurn; + + const responseParts: IResponsePart[] = active.responseParts.map(part => { + if (part.kind !== ResponsePartKind.ToolCall) { + return part; + } + const tc = part.toolCall; + if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { + return part; + } + // Force non-terminal tool calls into cancelled state + return { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Cancelled as const, + ...tcBase(tc), + invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage, + toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput, + reason: ToolCallCancellationReason.Skipped, + }, + }; + }); + + const turn: ITurn = { + id: active.id, + userMessage: active.userMessage, + responseParts, + usage: active.usage, + state: turnState, + error, + }; + + return { + ...state, + turns: [...state.turns, turn], + activeTurn: undefined, + summary: { ...state.summary, status: summaryStatus, modifiedAt: Date.now() }, + }; +} + +/** + * Immutably updates the tool call inside a `ToolCall` response part in the + * active turn's `responseParts` array. Returns `state` unchanged if the + * active turn or tool call doesn't match. + */ +function updateToolCallInParts( + state: ISessionState, + turnId: string, + toolCallId: string, + updater: (tc: IToolCallState) => IToolCallState, +): ISessionState { + const activeTurn = state.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return state; + } + + let found = false; + const responseParts = activeTurn.responseParts.map(part => { + if (part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === toolCallId) { + const updated = updater(part.toolCall); + if (updated === part.toolCall) { + return part; + } + found = true; + return { ...part, toolCall: updated }; + } + return part; + }); + + if (!found) { + return state; + } + + return { + ...state, + activeTurn: { ...activeTurn, responseParts }, + }; +} + +/** + * Immutably updates a response part by `partId` in the active turn. + * For markdown/reasoning parts, matches on `id`. For tool call parts, + * matches on `toolCall.toolCallId`. + */ +function updateResponsePart( + state: ISessionState, + turnId: string, + partId: string, + updater: (part: IResponsePart) => IResponsePart, +): ISessionState { + const activeTurn = state.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return state; + } + + let found = false; + const responseParts = activeTurn.responseParts.map(part => { + if (!found) { + const id = part.kind === ResponsePartKind.ToolCall + ? part.toolCall.toolCallId + : 'id' in part ? part.id : undefined; + if (id === partId) { + found = true; + return updater(part); + } + } + return part; + }); + + if (!found) { + return state; + } + + return { + ...state, + activeTurn: { ...activeTurn, responseParts }, + }; +} + +// ─── Root Reducer ──────────────────────────────────────────────────────────── + +/** + * Pure reducer for root state. Handles all {@link IRootAction} variants. + */ +export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: string) => void): IRootState { + switch (action.type) { + case ActionType.RootAgentsChanged: + return { ...state, agents: action.agents }; + + case ActionType.RootActiveSessionsChanged: + return { ...state, activeSessions: action.activeSessions }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Session Reducer ───────────────────────────────────────────────────────── + +/** + * Pure reducer for session state. Handles all {@link ISessionAction} variants. + */ +export function sessionReducer(state: ISessionState, action: ISessionAction, log?: (msg: string) => void): ISessionState { + switch (action.type) { + // ── Lifecycle ────────────────────────────────────────────────────────── + + case ActionType.SessionReady: + return { + ...state, + lifecycle: SessionLifecycle.Ready, + summary: { ...state.summary, status: SessionStatus.Idle }, + }; + + case ActionType.SessionCreationFailed: + return { + ...state, + lifecycle: SessionLifecycle.CreationFailed, + creationError: action.error, + }; + + // ── Turn Lifecycle ──────────────────────────────────────────────────── + + case ActionType.SessionTurnStarted: + return { + ...state, + summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() }, + activeTurn: { + id: action.turnId, + userMessage: action.userMessage, + responseParts: [], + usage: undefined, + }, + }; + + case ActionType.SessionDelta: + return updateResponsePart(state, action.turnId, action.partId, part => { + if (part.kind === ResponsePartKind.Markdown) { + return { ...part, content: part.content + action.content }; + } + return part; + }); + + case ActionType.SessionResponsePart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + responseParts: [...state.activeTurn.responseParts, action.part], + }, + }; + + case ActionType.SessionTurnComplete: + return endTurn(state, action.turnId, TurnState.Complete, SessionStatus.Idle); + + case ActionType.SessionTurnCancelled: + return endTurn(state, action.turnId, TurnState.Cancelled, SessionStatus.Idle); + + case ActionType.SessionError: + return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + + // ── Tool Call State Machine ─────────────────────────────────────────── + + case ActionType.SessionToolCallStart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + responseParts: [ + ...state.activeTurn.responseParts, + { + kind: ResponsePartKind.ToolCall, + toolCall: { + toolCallId: action.toolCallId, + toolName: action.toolName, + displayName: action.displayName, + toolClientId: action.toolClientId, + _meta: action._meta, + status: ToolCallStatus.Streaming, + }, + } satisfies IToolCallResponsePart, + ], + }, + }; + + case ActionType.SessionToolCallDelta: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Streaming) { + return tc; + } + return { + ...tc, + partialInput: (tc.partialInput ?? '') + action.content, + invocationMessage: action.invocationMessage ?? tc.invocationMessage, + }; + }); + + case ActionType.SessionToolCallReady: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Streaming && tc.status !== ToolCallStatus.Running) { + return tc; + } + const base = tcBase(tc); + if (action.confirmed) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.PendingConfirmation, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmationTitle: action.confirmationTitle, + }; + }); + + case ActionType.SessionToolCallConfirmed: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: action.reason, + reasonMessage: action.reasonMessage, + userSuggestion: action.userSuggestion, + }; + }); + + case ActionType.SessionToolCallComplete: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + const confirmed = tc.status === ToolCallStatus.Running + ? tc.confirmed + : ToolCallConfirmationReason.NotNeeded; + if (action.requiresResultConfirmation) { + return { + status: ToolCallStatus.PendingResultConfirmation, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + } + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + }); + + case ActionType.SessionToolCallResultConfirmed: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingResultConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: tc.confirmed, + success: tc.success, + pastTenseMessage: tc.pastTenseMessage, + content: tc.content, + structuredContent: tc.structuredContent, + error: tc.error, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: ToolCallCancellationReason.ResultDenied, + }; + }); + + // ── Metadata ────────────────────────────────────────────────────────── + + case ActionType.SessionTitleChanged: + return { + ...state, + summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, + }; + + case ActionType.SessionUsage: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { ...state.activeTurn, usage: action.usage }, + }; + + case ActionType.SessionReasoning: + return updateResponsePart(state, action.turnId, action.partId, part => { + if (part.kind === ResponsePartKind.Reasoning) { + return { ...part, content: part.content + action.content }; + } + return part; + }); + + case ActionType.SessionModelChanged: + return { + ...state, + summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, + }; + + case ActionType.SessionServerToolsChanged: + return { ...state, serverTools: action.tools }; + + case ActionType.SessionActiveClientChanged: + return { + ...state, + activeClient: action.activeClient ?? undefined, + }; + + case ActionType.SessionActiveClientToolsChanged: + if (!state.activeClient) { + return state; + } + return { + ...state, + activeClient: { ...state.activeClient, tools: action.tools }, + }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Dispatch Validation ───────────────────────────────────────────────────── + +/** + * Type guard that checks whether an action may be dispatched by a client. + * + * Servers SHOULD call this to validate incoming `dispatchAction` requests + * and reject any action the client is not allowed to originate. + */ +export function isClientDispatchable(action: ISessionAction): action is IClientSessionAction { + return IS_CLIENT_DISPATCHABLE[action.type]; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts new file mode 100644 index 0000000000000..1cd9bb66cc413 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -0,0 +1,806 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +// ─── Type Aliases ──────────────────────────────────────────────────────────── + +/** A URI string (e.g. `agenthost:/root` or `copilot:/`). */ +export type URI = string; + +/** + * A string that may optionally be rendered as Markdown. + * + * - A plain `string` is rendered as-is (no Markdown processing). + * - An object with `{ markdown: string }` is rendered with Markdown formatting. + */ +export type StringOrMarkdown = string | { markdown: string }; + +// ─── Protected Resource Metadata (RFC 9728) ───────────────────────────────── + +/** + * Describes a protected resource's authentication requirements using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 + * Protected Resource Metadata) semantics. + * + * Field names use snake_case to match the RFC 9728 JSON format. + * + * @category Authentication + * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + */ +export interface IProtectedResourceMetadata { + /** + * REQUIRED. The protected resource's resource identifier, a URL using the + * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + */ + resource: string; + + /** OPTIONAL. Human-readable name of the protected resource. */ + resource_name?: string; + + /** OPTIONAL. JSON array of OAuth authorization server identifier URLs. */ + authorization_servers?: string[]; + + /** OPTIONAL. URL of the protected resource's JWK Set document. */ + jwks_uri?: string; + + /** RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. */ + scopes_supported?: string[]; + + /** OPTIONAL. JSON array of Bearer Token presentation methods supported. */ + bearer_methods_supported?: string[]; + + /** OPTIONAL. JSON array of JWS signing algorithms supported. */ + resource_signing_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. */ + resource_encryption_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. */ + resource_encryption_enc_values_supported?: string[]; + + /** OPTIONAL. URL of human-readable documentation for the resource. */ + resource_documentation?: string; + + /** OPTIONAL. URL of the resource's data-usage policy. */ + resource_policy_uri?: string; + + /** OPTIONAL. URL of the resource's terms of service. */ + resource_tos_uri?: string; + + /** + * AHP extension. Whether authentication is required for this resource. + * + * - `true` (default) — the agent cannot be used without a valid token. + * The server SHOULD return `AuthRequired` (`-32007`) if the client + * attempts to use the agent without authenticating. + * - `false` — the agent works without authentication but MAY offer + * enhanced capabilities when a token is provided. + * + * Clients SHOULD treat an absent field the same as `true`. + */ + required?: boolean; +} + +// ─── Root State ────────────────────────────────────────────────────────────── + +/** + * Policy configuration state for a model. + * + * @category Root State + */ +export const enum PolicyState { + Enabled = 'enabled', + Disabled = 'disabled', + Unconfigured = 'unconfigured', +} + +/** + * Global state shared with every client subscribed to `agenthost:/root`. + * + * @category Root State + */ +export interface IRootState { + /** Available agent backends and their models */ + agents: IAgentInfo[]; + /** Number of active (non-disposed) sessions on the server */ + activeSessions?: number; +} + +/** + * @category Root State + */ +export interface IAgentInfo { + /** Agent provider ID (e.g. `'copilot'`) */ + provider: string; + /** Human-readable name */ + displayName: string; + /** Description string */ + description: string; + /** Available models for this agent */ + models: ISessionModelInfo[]; + /** + * Protected resources this agent requires authentication for. + * + * Each entry describes an OAuth 2.0 protected resource using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + * Clients should obtain tokens from the declared `authorization_servers` + * and push them via the `authenticate` command before creating sessions + * with this agent. + * + * @see {@link /specification/authentication | Authentication} + */ + protectedResources?: IProtectedResourceMetadata[]; +} + +/** + * @category Root State + */ +export interface ISessionModelInfo { + /** Model identifier */ + id: string; + /** Provider this model belongs to */ + provider: string; + /** Human-readable model name */ + name: string; + /** Maximum context window size */ + maxContextWindow?: number; + /** Whether the model supports vision */ + supportsVision?: boolean; + /** Policy configuration state */ + policyState?: PolicyState; +} + +// ─── Session State ─────────────────────────────────────────────────────────── + +/** + * Session initialization state. + * + * @category Session State + */ +export const enum SessionLifecycle { + Creating = 'creating', + Ready = 'ready', + CreationFailed = 'creationFailed', +} + +/** + * Current session status. + * + * @category Session State + */ +export const enum SessionStatus { + Idle = 'idle', + InProgress = 'in-progress', + Error = 'error', +} + +/** + * Full state for a single session, loaded when a client subscribes to the session's URI. + * + * @category Session State + */ +export interface ISessionState { + /** Lightweight session metadata */ + summary: ISessionSummary; + /** Session initialization state */ + lifecycle: SessionLifecycle; + /** Error details if creation failed */ + creationError?: IErrorInfo; + /** Tools provided by the server (agent host) for this session */ + serverTools?: IToolDefinition[]; + /** The client currently providing tools and interactive capabilities to this session */ + activeClient?: ISessionActiveClient; + /** The working directory URI for this session */ + workingDirectory?: URI; + /** Completed turns */ + turns: ITurn[]; + /** Currently in-progress turn */ + activeTurn?: IActiveTurn; +} + +/** + * The client currently providing tools and interactive capabilities to a session. + * + * Only one client may be active per session at a time. The server SHOULD + * automatically unset the active client if that client disconnects. + * + * @category Session State + */ +export interface ISessionActiveClient { + /** Client identifier (matches `clientId` from `initialize`) */ + clientId: string; + /** Human-readable client name (e.g. `"VS Code"`) */ + displayName?: string; + /** Tools this client provides to the session */ + tools: IToolDefinition[]; +} + +/** + * @category Session State + */ +export interface ISessionSummary { + /** Session URI */ + resource: URI; + /** Agent provider ID */ + provider: string; + /** Session title */ + title: string; + /** Current session status */ + status: SessionStatus; + /** Creation timestamp */ + createdAt: number; + /** Last modification timestamp */ + modifiedAt: number; + /** Currently selected model */ + model?: string; + /** The working directory URI for this session */ + workingDirectory?: URI; +} + +// ─── Turn Types ────────────────────────────────────────────────────────────── + +/** + * How a turn ended. + * + * @category Turn Types + */ +export const enum TurnState { + Complete = 'complete', + Cancelled = 'cancelled', + Error = 'error', +} + +/** + * Type of a message attachment. + * + * @category Turn Types + */ +export const enum AttachmentType { + File = 'file', + Directory = 'directory', + Selection = 'selection', +} + +/** + * A completed request/response cycle. + * + * @category Turn Types + */ +export interface ITurn { + /** Turn identifier */ + id: string; + /** The user's input */ + userMessage: IUserMessage; + /** + * All response content in stream order: text, tool calls, reasoning, and content refs. + * + * Consumers should derive display text by concatenating markdown parts, + * and find tool calls by filtering for `ToolCall` parts. + */ + responseParts: IResponsePart[]; + /** Token usage info */ + usage: IUsageInfo | undefined; + /** How the turn ended */ + state: TurnState; + /** Error details if state is `'error'` */ + error?: IErrorInfo; +} + +/** + * An in-progress turn — the assistant is actively streaming. + * + * @category Turn Types + */ +export interface IActiveTurn { + /** Turn identifier */ + id: string; + /** The user's input */ + userMessage: IUserMessage; + /** + * All response content in stream order: text, tool calls, reasoning, and content refs. + * + * Tool call parts include `pendingPermissions` when permissions are awaiting user approval. + */ + responseParts: IResponsePart[]; + /** Token usage info */ + usage: IUsageInfo | undefined; +} + +/** + * @category Turn Types + */ +export interface IUserMessage { + /** Message text */ + text: string; + /** File/selection attachments */ + attachments?: IMessageAttachment[]; +} + +/** + * @category Turn Types + */ +export interface IMessageAttachment { + /** Attachment type */ + type: AttachmentType; + /** File/directory path */ + path: string; + /** Display name */ + displayName?: string; +} + +// ─── Response Parts ────────────────────────────────────────────────────────── + +/** + * Discriminant for response part types. + * + * @category Response Parts + */ +export const enum ResponsePartKind { + Markdown = 'markdown', + ContentRef = 'contentRef', + ToolCall = 'toolCall', + Reasoning = 'reasoning', +} + +/** + * @category Response Parts + */ +export interface IMarkdownResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Markdown; + /** Part identifier, used by `session/delta` to target this part for content appends */ + id: string; + /** Markdown content */ + content: string; +} + +/** + * A reference to large content stored outside the state tree. + * + * @category Response Parts + */ +export interface IContentRef { + /** Discriminant */ + kind: ResponsePartKind.ContentRef; + /** Content URI */ + uri: string; + /** Approximate size in bytes */ + sizeHint?: number; + /** Content MIME type */ + contentType?: string; +} + +/** + * A tool call represented as a response part. + * + * Tool calls are part of the response stream, interleaved with text and + * reasoning. The `toolCall.toolCallId` serves as the part identifier for + * actions that target this part. + * + * @category Response Parts + */ +export interface IToolCallResponsePart { + /** Discriminant */ + kind: ResponsePartKind.ToolCall; + /** Full tool call lifecycle state */ + toolCall: IToolCallState; +} + +/** + * Reasoning/thinking content from the model. + * + * @category Response Parts + */ +export interface IReasoningResponsePart { + /** Discriminant */ + kind: ResponsePartKind.Reasoning; + /** Part identifier, used by `session/reasoning` to target this part for content appends */ + id: string; + /** Accumulated reasoning text */ + content: string; +} + +/** + * @category Response Parts + */ +export type IResponsePart = IMarkdownResponsePart | IContentRef | IToolCallResponsePart | IReasoningResponsePart; + +// ─── Tool Call Types ───────────────────────────────────────────────────────── + +/** + * Status of a tool call in the lifecycle state machine. + * + * @category Tool Call Types + */ +export const enum ToolCallStatus { + Streaming = 'streaming', + PendingConfirmation = 'pending-confirmation', + Running = 'running', + PendingResultConfirmation = 'pending-result-confirmation', + Completed = 'completed', + Cancelled = 'cancelled', +} + +/** + * How a tool call was confirmed for execution. + * + * - `NotNeeded` — No confirmation required (auto-approved) + * - `UserAction` — User explicitly approved + * - `Setting` — Approved by a persistent user setting + * + * @category Tool Call Types + */ +export const enum ToolCallConfirmationReason { + NotNeeded = 'not-needed', + UserAction = 'user-action', + Setting = 'setting', +} + +/** + * Why a tool call was cancelled. + * + * @category Tool Call Types + */ +export const enum ToolCallCancellationReason { + Denied = 'denied', + Skipped = 'skipped', + ResultDenied = 'result-denied', +} + +/** + * Metadata common to all tool call states. + * + * @category Tool Call Types + * @remarks + * Fields like `toolName` carry agent-specific identifiers on the wire despite the + * agent-agnostic design principle. These exist for debugging and logging purposes. + * A future version may move these to a separate diagnostic channel or namespace them + * more clearly. + */ +interface IToolCallBase { + /** Unique tool call identifier */ + toolCallId: string; + /** Internal tool name (for debugging/logging) */ + toolName: string; + /** Human-readable tool name */ + displayName: string; + /** + * If this tool is provided by a client, the `clientId` of the owning client. + * Absent for server-side tools. + * + * When set, the identified client is responsible for executing the tool and + * dispatching `session/toolCallComplete` with the result. + */ + toolClientId?: string; + /** + * Additional provider-specific metadata for this tool call. + * + * Clients MAY look for well-known keys here to provide enhanced UI. + * For example, a `ptyTerminal` key with `{ input: string; output: string }` + * indicates the tool operated on a terminal (both `input` and `output` may + * contain escape sequences). + */ + _meta?: Record; +} + +/** + * Properties available once tool call parameters are fully received. + * + * @category Tool Call Types + */ +interface IToolCallParameterFields { + /** Message describing what the tool will do */ + invocationMessage: StringOrMarkdown; + /** Raw tool input */ + toolInput?: string; +} + +/** + * Tool execution result details, available after execution completes. + * + * @category Tool Call Types + */ +export interface IToolCallResult { + /** Whether the tool succeeded */ + success: boolean; + /** Past-tense description of what the tool did */ + pastTenseMessage: StringOrMarkdown; + /** + * Unstructured result content blocks. + * + * This mirrors the `content` field of MCP `CallToolResult`. + */ + content?: IToolResultContent[]; + /** + * Optional structured result object. + * + * This mirrors the `structuredContent` field of MCP `CallToolResult`. + */ + structuredContent?: Record; + /** Error details if the tool failed */ + error?: { message: string; code?: string }; +} + +/** + * LM is streaming the tool call parameters. + * + * @category Tool Call Types + */ +export interface IToolCallStreamingState extends IToolCallBase { + status: ToolCallStatus.Streaming; + /** Partial parameters accumulated so far */ + partialInput?: string; + /** Progress message shown while parameters are streaming */ + invocationMessage?: StringOrMarkdown; +} + +/** + * Parameters are complete, or a running tool requires re-confirmation + * (e.g. a mid-execution permission check). + * + * @category Tool Call Types + */ +export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolCallParameterFields { + status: ToolCallStatus.PendingConfirmation; + /** Short title for the confirmation prompt (e.g. `"Run in terminal"`, `"Write file"`) */ + confirmationTitle?: StringOrMarkdown; +} + +/** + * Tool is actively executing. + * + * @category Tool Call Types + */ +export interface IToolCallRunningState extends IToolCallBase, IToolCallParameterFields { + status: ToolCallStatus.Running; + /** How the tool was confirmed for execution */ + confirmed: ToolCallConfirmationReason; +} + +/** + * Tool finished executing, waiting for client to approve the result. + * + * @category Tool Call Types + */ +export interface IToolCallPendingResultConfirmationState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { + status: ToolCallStatus.PendingResultConfirmation; + /** How the tool was confirmed for execution */ + confirmed: ToolCallConfirmationReason; +} + +/** + * Tool completed successfully or with an error. + * + * @category Tool Call Types + */ +export interface IToolCallCompletedState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { + status: ToolCallStatus.Completed; + /** How the tool was confirmed for execution */ + confirmed: ToolCallConfirmationReason; +} + +/** + * Tool call was cancelled before execution. + * + * @category Tool Call Types + */ +export interface IToolCallCancelledState extends IToolCallBase, IToolCallParameterFields { + status: ToolCallStatus.Cancelled; + /** Why the tool was cancelled */ + reason: ToolCallCancellationReason; + /** Optional message explaining the cancellation */ + reasonMessage?: StringOrMarkdown; + /** What the user suggested doing instead */ + userSuggestion?: IUserMessage; +} + +/** + * Discriminated union of all tool call lifecycle states. + * + * See the [state model guide](/guide/state-model.html#tool-call-lifecycle) + * for the full state machine diagram. + * + * @category Tool Call Types + */ +export type IToolCallState = + | IToolCallStreamingState + | IToolCallPendingConfirmationState + | IToolCallRunningState + | IToolCallPendingResultConfirmationState + | IToolCallCompletedState + | IToolCallCancelledState; + +// ─── Tool Definition Types ─────────────────────────────────────────────────── + +/** + * Describes a tool available in a session, provided by either the server or the active client. + * + * This type mirrors the MCP `Tool` type from the Model Context Protocol specification + * (2025-11-25 draft) and will continue to track it. + * + * @category Tool Definition Types + */ +export interface IToolDefinition { + /** Unique tool identifier */ + name: string; + /** Human-readable display name */ + title?: string; + /** Description of what the tool does */ + description?: string; + /** + * JSON Schema defining the expected input parameters. + * + * Optional because client-provided tools may not have formal schemas. + * Mirrors MCP `Tool.inputSchema`. + */ + inputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * JSON Schema defining the structure of the tool's output. + * + * Mirrors MCP `Tool.outputSchema`. + */ + outputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** Behavioral hints about the tool. All properties are advisory. */ + annotations?: IToolAnnotations; + /** + * Additional provider-specific metadata. + * + * Mirrors the MCP `_meta` convention. + */ + _meta?: Record; +} + +/** + * Behavioral hints about a tool. All properties are advisory and not + * guaranteed to faithfully describe tool behavior. + * + * Mirrors MCP `ToolAnnotations` from the Model Context Protocol specification. + * + * @category Tool Definition Types + */ +export interface IToolAnnotations { + /** Alternate human-readable title */ + title?: string; + /** Tool does not modify its environment (default: false) */ + readOnlyHint?: boolean; + /** Tool may perform destructive updates (default: true) */ + destructiveHint?: boolean; + /** Repeated calls with the same arguments have no additional effect (default: false) */ + idempotentHint?: boolean; + /** Tool may interact with external entities (default: true) */ + openWorldHint?: boolean; +} + +// ─── Tool Result Content ───────────────────────────────────────────────────── + +/** + * Discriminant for tool result content types. + * + * @category Tool Result Content + */ +export const enum ToolResultContentType { + Text = 'text', + Binary = 'binary', + FileEdit = 'fileEdit', +} + +/** + * Text content in a tool result. + * + * Mirrors MCP `TextContent`. + * + * @category Tool Result Content + */ +export interface IToolResultTextContent { + type: ToolResultContentType.Text; + /** The text content */ + text: string; +} + +/** + * Base64-encoded binary content in a tool result. + * + * Mirrors MCP `ImageContent` but generalized to any binary content type. + * + * @category Tool Result Content + */ +export interface IToolResultBinaryContent { + type: ToolResultContentType.Binary; + /** Base64-encoded data */ + data: string; + /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ + contentType: string; +} + +/** + * Describes a file modification performed by a tool. + * + * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** URI of the file content before the edit */ + beforeURI: URI; + /** URI of the file content after the edit */ + afterURI: URI; + /** Optional diff display metadata */ + diff?: { + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; + }; +} + +/** + * Content block in a tool result. + * + * Mirrors the content blocks in MCP `CallToolResult.content`, plus + * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` + * for file edit diffs (AHP extensions). + * + * @category Tool Result Content + */ +export type IToolResultContent = + | IToolResultTextContent + | IToolResultBinaryContent + | IToolResultFileEditContent + | IContentRef; + +// ─── Common Types ──────────────────────────────────────────────────────────── + +/** + * @category Common Types + */ +export interface IUsageInfo { + /** Input tokens consumed */ + inputTokens?: number; + /** Output tokens generated */ + outputTokens?: number; + /** Model used */ + model?: string; + /** Tokens read from cache */ + cacheReadTokens?: number; +} + +/** + * @category Common Types + */ +export interface IErrorInfo { + /** Error type identifier */ + errorType: string; + /** Human-readable error message */ + message: string; + /** Stack trace */ + stack?: string; +} + +/** + * A point-in-time snapshot of a subscribed resource's state, returned by + * `initialize`, `reconnect`, and `subscribe`. + * + * @category Common Types + */ +export interface ISnapshot { + /** The subscribed resource URI (e.g. `agenthost:/root` or `copilot:/`) */ + resource: URI; + /** The current state of the resource */ + state: IRootState | ISessionState; + /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ + fromSeq: number; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts new file mode 100644 index 0000000000000..5a5ce1a5380fb --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 69603e5 + +import { ActionType, type IStateAction } from '../actions.js'; +import { NotificationType, type IProtocolNotification } from '../notifications.js'; + +// ─── Protocol Version Constants ────────────────────────────────────────────── + +/** The current protocol version that new code speaks. */ +export const PROTOCOL_VERSION = 1; + +/** The oldest protocol version the implementation maintains compatibility with. */ +export const MIN_PROTOCOL_VERSION = 1; + +// ─── Exhaustive Action → Version Map ───────────────────────────────────────── + +/** + * Maps every action type to the protocol version that introduced it. + * Adding a new action to `IStateAction` without adding it here is a compile error. + */ +export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { + [ActionType.RootAgentsChanged]: 1, + [ActionType.RootActiveSessionsChanged]: 1, + [ActionType.SessionReady]: 1, + [ActionType.SessionCreationFailed]: 1, + [ActionType.SessionTurnStarted]: 1, + [ActionType.SessionDelta]: 1, + [ActionType.SessionResponsePart]: 1, + [ActionType.SessionToolCallStart]: 1, + [ActionType.SessionToolCallDelta]: 1, + [ActionType.SessionToolCallReady]: 1, + [ActionType.SessionToolCallConfirmed]: 1, + [ActionType.SessionToolCallComplete]: 1, + [ActionType.SessionToolCallResultConfirmed]: 1, + [ActionType.SessionTurnComplete]: 1, + [ActionType.SessionTurnCancelled]: 1, + [ActionType.SessionError]: 1, + [ActionType.SessionTitleChanged]: 1, + [ActionType.SessionUsage]: 1, + [ActionType.SessionReasoning]: 1, + [ActionType.SessionModelChanged]: 1, + [ActionType.SessionServerToolsChanged]: 1, + [ActionType.SessionActiveClientChanged]: 1, + [ActionType.SessionActiveClientToolsChanged]: 1, +}; + +/** + * Returns whether the given action type is known to the specified protocol version. + */ +export function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { + return ACTION_INTRODUCED_IN[action.type] <= clientVersion; +} + +// ─── Exhaustive Notification → Version Map ───────────────────────────────── + +/** + * Maps every notification type to the protocol version that introduced it. + * Adding a new notification to `IProtocolNotification` without adding it here + * is a compile error. + */ +export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { + [NotificationType.SessionAdded]: 1, + [NotificationType.SessionRemoved]: 1, + [NotificationType.AuthRequired]: 1, +}; + +/** + * Returns whether the given notification type is known to the specified protocol version. + */ +export function isNotificationKnownToVersion(notification: IProtocolNotification, clientVersion: number): boolean { + return NOTIFICATION_INTRODUCED_IN[notification.type] <= clientVersion; +} + +// ─── Capabilities ──────────────────────────────────────────────────────────── + +/** + * Feature capabilities gated by protocol version. + */ +export interface ProtocolCapabilities { + /** v1 — always present */ + readonly sessions: true; + /** v1 — always present */ + readonly tools: true; + /** v1 — always present */ + readonly permissions: true; +} + +/** + * Derives capabilities from a protocol version number. + */ +export function capabilitiesForVersion(_version: number): ProtocolCapabilities { + return { + sessions: true, + tools: true, + permissions: true, + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts new file mode 100644 index 0000000000000..d63ec1d7e5e2b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Action and notification types for the sessions process protocol. +// Re-exports from the auto-generated protocol layer with local aliases. +// +// VS Code-specific additions: +// - IToolCallStartAction extends protocol with `toolKind` and `language` +// - isRootAction / isSessionAction type guards +// - INotification alias for IProtocolNotification + +// ---- Re-exports from protocol ----------------------------------------------- + +export { + ActionType, + type IActionEnvelope, + type IActionOrigin, + type IRootAgentsChangedAction, + type IRootActiveSessionsChangedAction, + type ISessionCreationFailedAction, + type ISessionDeltaAction, + type ISessionErrorAction, + type ISessionModelChangedAction, + type ISessionReadyAction, + type ISessionReasoningAction, + type ISessionResponsePartAction, + type ISessionToolCallCompleteAction, + type ISessionToolCallConfirmedAction, + type ISessionToolCallApprovedAction, + type ISessionToolCallDeniedAction, + type ISessionToolCallDeltaAction, + type ISessionToolCallReadyAction, + type ISessionToolCallResultConfirmedAction, + type ISessionToolCallStartAction, + type ISessionTitleChangedAction, + type ISessionTurnCancelledAction, + type ISessionTurnCompleteAction, + type ISessionTurnStartedAction, + type ISessionUsageAction, + type ISessionServerToolsChangedAction, + type ISessionActiveClientChangedAction, + type ISessionActiveClientToolsChangedAction, + type IStateAction, +} from './protocol/actions.js'; + +export { + NotificationType, + AuthRequiredReason, + type ISessionAddedNotification, + type ISessionRemovedNotification, + type IAuthRequiredNotification, +} from './protocol/notifications.js'; + +// ---- Local aliases for short names ------------------------------------------ +// Consumers use these shorter names; they're type-only aliases. + +import type { + IRootAgentsChangedAction, + IRootActiveSessionsChangedAction, + ISessionDeltaAction, + ISessionModelChangedAction, + ISessionReasoningAction, + ISessionResponsePartAction, + ISessionToolCallCompleteAction, + ISessionToolCallConfirmedAction, + ISessionToolCallDeltaAction, + ISessionToolCallReadyAction, + ISessionToolCallResultConfirmedAction, + ISessionToolCallStartAction, + ISessionTitleChangedAction, + ISessionTurnCancelledAction, + ISessionTurnCompleteAction, + ISessionTurnStartedAction, + ISessionUsageAction, + IStateAction, +} from './protocol/actions.js'; + +import type { IProtocolNotification } from './protocol/notifications.js'; +import type { IRootAction as IRootAction_, ISessionAction as ISessionAction_, IClientSessionAction as IClientSessionAction_, IServerSessionAction as IServerSessionAction_ } from './protocol/action-origin.generated.js'; + +export type IRootAction = IRootAction_; +export type ISessionAction = ISessionAction_; +export type IClientSessionAction = IClientSessionAction_; +export type IServerSessionAction = IServerSessionAction_; + +// Root actions +export type IAgentsChangedAction = IRootAgentsChangedAction; +export type IActiveSessionsChangedAction = IRootActiveSessionsChangedAction; + +// Session actions — short aliases +export type ITurnStartedAction = ISessionTurnStartedAction; +export type IDeltaAction = ISessionDeltaAction; +export type IResponsePartAction = ISessionResponsePartAction; +export type IToolCallStartAction = ISessionToolCallStartAction; +export type IToolCallDeltaAction = ISessionToolCallDeltaAction; +export type IToolCallReadyAction = ISessionToolCallReadyAction; +export type IToolCallApprovedAction = import('./protocol/actions.js').ISessionToolCallApprovedAction; +export type IToolCallDeniedAction = import('./protocol/actions.js').ISessionToolCallDeniedAction; +export type IToolCallConfirmedAction = ISessionToolCallConfirmedAction; +export type IToolCallCompleteAction = ISessionToolCallCompleteAction; +export type IToolCallResultConfirmedAction = ISessionToolCallResultConfirmedAction; +export type ITurnCompleteAction = ISessionTurnCompleteAction; +export type ITurnCancelledAction = ISessionTurnCancelledAction; +export type ITitleChangedAction = ISessionTitleChangedAction; +export type IUsageAction = ISessionUsageAction; +export type IReasoningAction = ISessionReasoningAction; +export type IModelChangedAction = ISessionModelChangedAction; + +// Notifications +export type INotification = IProtocolNotification; + +// ---- Type guards ------------------------------------------------------------ + +export function isRootAction(action: IStateAction): action is IRootAction { + return action.type.startsWith('root/'); +} + +export function isSessionAction(action: IStateAction): action is ISessionAction { + return action.type.startsWith('session/'); +} diff --git a/src/vs/platform/agentHost/common/state/sessionCapabilities.ts b/src/vs/platform/agentHost/common/state/sessionCapabilities.ts new file mode 100644 index 0000000000000..b10b8ca466403 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionCapabilities.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol version constants and capability derivation. +// See protocol.md -> Versioning for the full design. +// +// The authoritative version numbers and action-filtering logic live in +// versions/versionRegistry.ts. This file re-exports them and provides the +// capability-object API that client code uses to gate features. + +export { + ACTION_INTRODUCED_IN, + isActionKnownToVersion, + isNotificationKnownToVersion, + MIN_PROTOCOL_VERSION, + NOTIFICATION_INTRODUCED_IN, + PROTOCOL_VERSION, +} from './versions/versionRegistry.js'; + +/** + * Capabilities derived from a protocol version. + * Core features (v1) are always-present literal `true`. + * Features from later versions are optional `true | undefined`. + */ +export interface ProtocolCapabilities { + // v1 — always present + readonly sessions: true; + readonly tools: true; + readonly permissions: true; +} + +/** + * Derives the set of capabilities available at a given protocol version. + * Newer clients use this to determine which features the server supports. + */ +export function capabilitiesForVersion(version: number): ProtocolCapabilities { + if (version < 1) { + throw new Error(`Unsupported protocol version: ${version}`); + } + + return { + sessions: true, + tools: true, + permissions: true, + // Future versions add fields here: + // ...(version >= 2 ? { reasoning: true as const } : {}), + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts new file mode 100644 index 0000000000000..f40695bcf0503 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Client-side state manager for the sessions process protocol. +// See protocol.md -> Write-ahead reconciliation for the full design. +// +// Manages confirmed state (last server-acknowledged), pending actions queue +// (optimistically applied), and reconciliation when the server echoes back +// or sends concurrent actions from other sources. +// +// This operates on two kinds of subscribable state: +// - Root state (agents + their models) — server-only mutations, no write-ahead. +// - Session state — mixed: some actions client-sendable (write-ahead), +// others server-only. + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js'; +import { rootReducer, sessionReducer } from './sessionReducers.js'; +import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js'; +import { ILogService } from '../../../log/common/log.js'; + +// ---- Pending action tracking ------------------------------------------------ + +interface IPendingAction { + readonly clientSeq: number; + readonly action: IStateAction; +} + +// ---- Client state manager --------------------------------------------------- + +/** + * Manages the client's local view of the state tree with write-ahead + * reconciliation. The client can optimistically apply its own session + * actions and reconcile when the server echoes them back (possibly + * interleaved with actions from other clients or the server). + * + * Usage: + * 1. Call `handleSnapshot(resource, state, fromSeq)` for each snapshot + * from the handshake or a subscribe response. + * 2. Call `applyOptimistic(action)` when the user does something + * (returns a clientSeq for the command). + * 3. Call `receiveEnvelope(envelope)` for each action from the server. + * 4. Call `receiveNotification(notification)` for each notification. + * 5. Read `rootState` / `getSessionState(uri)` for the current view. + */ +export class SessionClientState extends Disposable { + + private readonly _clientId: string; + private readonly _log: (msg: string) => void; + private _nextClientSeq = 1; + private _lastSeenServerSeq = 0; + + // Confirmed state — reflects only what the server has acknowledged + private _confirmedRootState: IRootState | undefined; + private readonly _confirmedSessionStates = new Map(); + + // Pending session actions (root actions are server-only, never pending) + private readonly _pendingActions: IPendingAction[] = []; + + // Cached optimistic state — recomputed when confirmed or pending changes + private _optimisticRootState: IRootState | undefined; + private readonly _optimisticSessionStates = new Map(); + + private readonly _onDidChangeRootState = this._register(new Emitter()); + readonly onDidChangeRootState: Event = this._onDidChangeRootState.event; + + private readonly _onDidChangeSessionState = this._register(new Emitter<{ session: string; state: ISessionState }>()); + readonly onDidChangeSessionState: Event<{ session: string; state: ISessionState }> = this._onDidChangeSessionState.event; + + private readonly _onDidReceiveNotification = this._register(new Emitter()); + readonly onDidReceiveNotification: Event = this._onDidReceiveNotification.event; + + constructor(clientId: string, logService: ILogService) { + super(); + this._clientId = clientId; + this._log = msg => logService.warn(`[SessionClientState] ${msg}`); + } + + get clientId(): string { + return this._clientId; + } + + get lastSeenServerSeq(): number { + return this._lastSeenServerSeq; + } + + /** Current root state, or undefined if not yet subscribed. */ + get rootState(): IRootState | undefined { + return this._optimisticRootState; + } + + /** Current optimistic session state, or undefined if not subscribed. */ + getSessionState(session: string): ISessionState | undefined { + return this._optimisticSessionStates.get(session); + } + + /** URIs of sessions the client is currently subscribed to. */ + get subscribedSessions(): readonly URI[] { + return [...this._confirmedSessionStates.keys()].map(k => URI.parse(k)); + } + + // ---- Snapshot handling --------------------------------------------------- + + /** + * Apply a state snapshot received from the server (from handshake, + * subscribe response, or reconnection). + */ + handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void { + this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, fromSeq); + + if (resource === ROOT_STATE_URI) { + const rootState = state as IRootState; + this._confirmedRootState = rootState; + this._optimisticRootState = rootState; + this._onDidChangeRootState.fire(rootState); + } else { + const sessionState = state as ISessionState; + this._confirmedSessionStates.set(resource, sessionState); + this._optimisticSessionStates.set(resource, sessionState); + // Re-apply any pending session actions for this session + this._recomputeOptimisticSession(resource); + this._onDidChangeSessionState.fire({ + session: resource, + state: this._optimisticSessionStates.get(resource)!, + }); + } + } + + /** + * Unsubscribe from a resource, dropping its local state. + */ + unsubscribe(resource: string): void { + if (resource === ROOT_STATE_URI) { + this._confirmedRootState = undefined; + this._optimisticRootState = undefined; + } else { + this._confirmedSessionStates.delete(resource); + this._optimisticSessionStates.delete(resource); + // Remove pending actions for this session + for (let i = this._pendingActions.length - 1; i >= 0; i--) { + const action = this._pendingActions[i].action; + if (isSessionAction(action) && action.session === resource) { + this._pendingActions.splice(i, 1); + } + } + } + } + + // ---- Write-ahead -------------------------------------------------------- + + /** + * Optimistically apply a session action locally. Returns the clientSeq + * that should be sent to the server with the corresponding command so + * the server can echo it back for reconciliation. + * + * Only session actions can be write-ahead (root actions are server-only). + */ + applyOptimistic(action: ISessionAction): number { + const clientSeq = this._nextClientSeq++; + this._pendingActions.push({ clientSeq, action }); + this._applySessionToOptimistic(action); + return clientSeq; + } + + // ---- Receiving server messages ------------------------------------------ + + /** + * Process an action envelope received from the server. + * This is the core reconciliation algorithm. + */ + receiveEnvelope(envelope: IActionEnvelope): void { + this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, envelope.serverSeq); + + const origin = envelope.origin; + const isOwnAction = origin !== undefined && origin.clientId === this._clientId; + + if (isOwnAction) { + const headIdx = this._pendingActions.findIndex(p => p.clientSeq === origin.clientSeq); + + if (headIdx !== -1) { + if (envelope.rejectionReason) { + this._pendingActions.splice(headIdx, 1); + } else { + this._applyToConfirmed(envelope.action); + this._pendingActions.splice(headIdx, 1); + } + } else { + this._applyToConfirmed(envelope.action); + } + } else { + this._applyToConfirmed(envelope.action); + } + + // Recompute optimistic state from confirmed + remaining pending + this._recomputeOptimistic(envelope.action); + } + + /** + * Process an ephemeral notification from the server. + * Not stored in state — just forwarded to listeners. + */ + receiveNotification(notification: INotification): void { + this._onDidReceiveNotification.fire(notification); + } + + // ---- Internal state management ------------------------------------------ + + private _applyToConfirmed(action: IStateAction): void { + if (isRootAction(action) && this._confirmedRootState) { + this._confirmedRootState = rootReducer(this._confirmedRootState, action, this._log); + } + if (isSessionAction(action)) { + const key = action.session.toString(); + const state = this._confirmedSessionStates.get(key); + if (state) { + this._confirmedSessionStates.set(key, sessionReducer(state, action, this._log)); + } + } + } + + private _applySessionToOptimistic(action: ISessionAction): void { + const key = action.session.toString(); + const state = this._optimisticSessionStates.get(key); + if (state) { + const newState = sessionReducer(state, action, this._log); + this._optimisticSessionStates.set(key, newState); + this._onDidChangeSessionState.fire({ session: action.session, state: newState }); + } + } + + /** + * After applying a server action to confirmed state, recompute optimistic + * state by replaying pending actions on top of confirmed. + */ + private _recomputeOptimistic(triggerAction: IStateAction): void { + // Root state: no pending actions (server-only), so optimistic = confirmed + if (isRootAction(triggerAction) && this._confirmedRootState) { + this._optimisticRootState = this._confirmedRootState; + this._onDidChangeRootState.fire(this._confirmedRootState); + } + + // Session states: recompute only affected sessions + if (isSessionAction(triggerAction)) { + this._recomputeOptimisticSession(triggerAction.session); + } + + // Also recompute any sessions that have pending actions + const affectedKeys = new Set(); + for (const pending of this._pendingActions) { + if (isSessionAction(pending.action)) { + affectedKeys.add(pending.action.session.toString()); + } + } + for (const key of affectedKeys) { + this._recomputeOptimisticSession(key); + } + } + + private _recomputeOptimisticSession(session: string): void { + const confirmed = this._confirmedSessionStates.get(session); + if (!confirmed) { + return; + } + + let state = confirmed; + for (const pending of this._pendingActions) { + if (isSessionAction(pending.action) && pending.action.session === session) { + state = sessionReducer(state, pending.action, this._log); + } + } + + this._optimisticSessionStates.set(session, state); + this._onDidChangeSessionState.fire({ session, state }); + } +} diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts new file mode 100644 index 0000000000000..deca30524f4e4 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol messages using JSON-RPC 2.0 framing for the sessions process. +// See protocol.md for the full design. +// +// Most types are re-exported from the auto-generated protocol layer. +// This file adds VS Code-specific additions (ISetAuthTokenParams, ProtocolError) +// and backward-compatible aliases. + +// ---- Re-exports from protocol ----------------------------------------------- + +// JSON-RPC base types +export type { + IJsonRpcErrorResponse, + IJsonRpcNotification, + IJsonRpcRequest, + IJsonRpcResponse, + IJsonRpcSuccessResponse, +} from './protocol/messages.js'; + +// Typed message unions +export type { + IAhpClientNotification, + IAhpNotification, + IAhpRequest, + IAhpResponse, + IAhpServerNotification, + IAhpSuccessResponse, + ICommandMap, + IClientNotificationMap, + INotificationMap, + INotificationMethodParams, + IProtocolMessage, + IServerNotificationMap, +} from './protocol/messages.js'; + +// Command params and results +export type { + IBrowseDirectoryParams, + IBrowseDirectoryResult, + ICreateSessionParams, + IDirectoryEntry, + IDispatchActionParams, + IDisposeSessionParams, + IFetchContentParams, + IFetchContentResult, + IFetchTurnsParams, + IFetchTurnsResult, + IInitializeParams, + IInitializeResult, + IListSessionsParams, + IListSessionsResult, + IReconnectParams, + IReconnectReplayResult, + IReconnectResult, + IReconnectSnapshotResult, + ISubscribeParams, + IUnsubscribeParams, +} from './protocol/commands.js'; + +export { ContentEncoding, ReconnectResultType } from './protocol/commands.js'; + +// Error codes +export { AhpErrorCodes, JsonRpcErrorCodes } from './protocol/errors.js'; +export type { AhpErrorCode, JsonRpcErrorCode } from './protocol/errors.js'; + +// Snapshot type (re-exported from state) +export type { ISnapshot as IStateSnapshot } from './protocol/state.js'; + +// ---- Backward-compatible error code aliases --------------------------------- + +export const JSON_RPC_PARSE_ERROR = -32700 as const; +export const JSON_RPC_INTERNAL_ERROR = -32603 as const; +export const AHP_SESSION_NOT_FOUND = -32001 as const; +export const AHP_PROVIDER_NOT_FOUND = -32002 as const; +export const AHP_SESSION_ALREADY_EXISTS = -32003 as const; +export const AHP_TURN_IN_PROGRESS = -32004 as const; +export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; +export const AHP_CONTENT_NOT_FOUND = -32006 as const; +export const AHP_AUTH_REQUIRED = -32007 as const; + +// ---- Type guards ----------------------------------------------------------- + +import type { IAhpRequest, IAhpNotification, IAhpSuccessResponse, IProtocolMessage, IJsonRpcErrorResponse } from './protocol/messages.js'; + +export function isJsonRpcRequest(msg: IProtocolMessage): msg is IAhpRequest { + return 'method' in msg && 'id' in msg; +} + +export function isJsonRpcNotification(msg: IProtocolMessage): msg is IAhpNotification { + return 'method' in msg && !('id' in msg); +} + +export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResponse | IJsonRpcErrorResponse { + return 'id' in msg && !('method' in msg); +} + +// ---- VS Code-specific types ------------------------------------------------ + +/** + * Error with a JSON-RPC error code for protocol-level failures. + * Optionally carries a `data` payload for structured error details. + */ +export class ProtocolError extends Error { + constructor(readonly code: number, message: string, readonly data?: unknown) { + super(message); + } +} + +/** + * VS Code-specific extension: set the auth token on the server. + * Not yet part of the official protocol. + */ +export interface ISetAuthTokenParams { + readonly token: string; +} + +// ---- Server → Client notification param aliases (backward compat) ----------- + +import type { INotification } from './sessionActions.js'; + +export interface INotificationBroadcastParams { + readonly notification: INotification; +} diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts new file mode 100644 index 0000000000000..3b02a189e5dea --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Re-exports the protocol reducers and adds VS Code-specific helpers. +// The actual reducer logic lives in the auto-generated protocol layer. + +import type { IToolCallState, ICompletedToolCall } from './sessionState.js'; + +// Re-export reducers from the protocol layer +export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; + +// ---- Tool call metadata helpers (VS Code extensions via _meta) -------------- + +/** + * Extracts the VS Code-specific `toolKind` rendering hint from a tool call's `_meta`. + */ +export function getToolKind(tc: IToolCallState | ICompletedToolCall): 'terminal' | undefined { + return tc._meta?.toolKind as 'terminal' | undefined; +} + +/** + * Extracts the VS Code-specific `language` hint from a tool call's `_meta`. + */ +export function getToolLanguage(tc: IToolCallState | ICompletedToolCall): string | undefined { + return tc._meta?.language as string | undefined; +} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts new file mode 100644 index 0000000000000..6915161eac98a --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Immutable state types for the sessions process protocol. +// See protocol.md for the full design rationale. +// +// Most types are imported from the auto-generated protocol layer +// (synced from the agent-host-protocol repo). This file adds VS Code-specific +// helpers and re-exports. + +import { hasKey } from '../../../../base/common/types.js'; +import { + SessionLifecycle, + ToolResultContentType, + IToolResultFileEditContent, + type IActiveTurn, + type IRootState, + type ISessionState, + type ISessionSummary, + type IToolCallCancelledState, + type IToolCallCompletedState, + type IToolCallResult, + type IToolCallState, + type IToolResultTextContent, + type IUserMessage, +} from './protocol/state.js'; + +// Re-export everything from the protocol state module +export { + type IActiveTurn, + type IAgentInfo, + type IContentRef, + type IErrorInfo, + type IMarkdownResponsePart, + type IMessageAttachment, + type IReasoningResponsePart, + type IResponsePart, + type IRootState, + type ISessionActiveClient, + type ISessionModelInfo, + type ISessionState, + type ISessionSummary, + type ISnapshot, + type IToolAnnotations, + type IToolCallCancelledState, + type IToolCallCompletedState, + type IToolCallPendingConfirmationState, + type IToolCallPendingResultConfirmationState, + type IToolCallResponsePart, + type IToolCallResult, + type IToolCallRunningState, + type IToolCallState, + type IToolCallStreamingState, + type IToolDefinition, + type IToolResultBinaryContent, + type IToolResultContent, + type IToolResultFileEditContent, + type IToolResultTextContent, + type ITurn, + type IUsageInfo, + type IUserMessage, + type StringOrMarkdown, + type URI, + AttachmentType, + PolicyState, + ResponsePartKind, + SessionLifecycle, + SessionStatus, + ToolCallConfirmationReason, + ToolCallCancellationReason, + ToolCallStatus, + ToolResultContentType, + TurnState, +} from './protocol/state.js'; + +// ---- Well-known URIs -------------------------------------------------------- + +/** URI for the root state subscription. */ +export const ROOT_STATE_URI = 'agenthost:/root'; + +// ---- VS Code-specific derived types ----------------------------------------- + +/** + * A tool call in a terminal state, stored in completed turns. + */ +export type ICompletedToolCall = IToolCallCompletedState | IToolCallCancelledState; + +/** + * Derived status type for the tool call lifecycle. + */ +export type ToolCallStatusString = IToolCallState['status']; + +// ---- Tool output helper ----------------------------------------------------- + +/** + * Extracts a plain-text tool output string from a tool call result's `content` + * array. Joins all text-type content parts into a single string. + * + * Returns `undefined` if there are no text content parts. + */ +export function getToolOutputText(result: IToolCallResult): string | undefined { + if (!result.content || result.content.length === 0) { + return undefined; + } + const textParts: IToolResultTextContent[] = []; + for (const c of result.content) { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Text) { + textParts.push(c); + } + } + if (textParts.length === 0) { + return undefined; + } + return textParts.map(p => p.text).join('\n'); +} + +/** + * Extracts file edit content entries from a tool call result's `content` array. + * Returns an empty array if there are no file edit content parts. + */ +export function getToolFileEdits(result: IToolCallResult): IToolResultFileEditContent[] { + if (!result.content || result.content.length === 0) { + return []; + } + const edits: IToolResultFileEditContent[] = []; + for (const c of result.content) { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.FileEdit) { + edits.push(c); + } + } + return edits; +} + +// ---- Factory helpers -------------------------------------------------------- + +export function createRootState(): IRootState { + return { + agents: [], + activeSessions: 0, + }; +} + +export function createSessionState(summary: ISessionSummary): ISessionState { + return { + summary, + lifecycle: SessionLifecycle.Creating, + turns: [], + activeTurn: undefined, + }; +} + +export function createActiveTurn(id: string, userMessage: IUserMessage): IActiveTurn { + return { + id, + userMessage, + responseParts: [], + usage: undefined, + }; +} diff --git a/src/vs/platform/agentHost/common/state/sessionTransport.ts b/src/vs/platform/agentHost/common/state/sessionTransport.ts new file mode 100644 index 0000000000000..20ce418469356 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionTransport.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Transport abstraction for the sessions process protocol. +// See protocol.md -> Client-server protocol for the full design. +// +// The transport is pluggable — the same protocol runs over MessagePort +// (ProxyChannel), WebSocket, or stdio. This module defines the contract; +// concrete implementations live in platform-specific folders. + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import type { IProtocolMessage, IAhpServerNotification, IJsonRpcResponse } from './sessionProtocol.js'; + +/** + * A bidirectional transport for protocol messages. Implementations handle + * serialization, framing, and connection management. + */ +export interface IProtocolTransport extends IDisposable { + /** Fires when a message is received from the remote end. */ + readonly onMessage: Event; + + /** Fires when the transport connection closes. */ + readonly onClose: Event; + + /** + * Send a message to the remote end. + * + * Accepts: + * - `IProtocolMessage` — fully-typed client↔server messages. + * - `IAhpServerNotification` — server→client notifications. + * - `IJsonRpcResponse` — dynamically-constructed success/error responses. + */ + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void; +} + +/** + * Server-side transport that accepts multiple client connections. + * Each connected client gets its own {@link IProtocolTransport}. + */ +export interface IProtocolServer extends IDisposable { + /** Fires when a new client connects. */ + readonly onConnection: Event; + + /** The port or address the server is listening on. */ + readonly address: string | undefined; +} diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts new file mode 100644 index 0000000000000..0ea965c5542cb --- /dev/null +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Version registry: re-exports protocol version constants and provides +// runtime action-filtering helpers using the local action union types. + +import type { INotification, IStateAction } from '../sessionActions.js'; + +// Re-export version constants from the protocol. +export { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../protocol/version/registry.js'; + +// ---- Runtime action → version map ------------------------------------------- + +/** Maps every action type string to the protocol version that introduced it. */ +export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { + // Root actions (v1) + 'root/agentsChanged': 1, + 'root/activeSessionsChanged': 1, + // Session lifecycle (v1) + 'session/ready': 1, + 'session/creationFailed': 1, + // Turn lifecycle (v1) + 'session/turnStarted': 1, + 'session/delta': 1, + 'session/responsePart': 1, + // Tool calls (v1) + 'session/toolCallStart': 1, + 'session/toolCallDelta': 1, + 'session/toolCallReady': 1, + 'session/toolCallConfirmed': 1, + 'session/toolCallComplete': 1, + 'session/toolCallResultConfirmed': 1, + // Turn completion (v1) + 'session/turnComplete': 1, + 'session/turnCancelled': 1, + 'session/error': 1, + // Metadata & informational (v1) + 'session/titleChanged': 1, + 'session/usage': 1, + 'session/reasoning': 1, + 'session/modelChanged': 1, + // Server tools & active client (v1) + 'session/serverToolsChanged': 1, + 'session/activeClientChanged': 1, + 'session/activeClientToolsChanged': 1, +}; + +/** Maps every notification type string to the protocol version that introduced it. */ +export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { + 'notify/sessionAdded': 1, + 'notify/sessionRemoved': 1, + 'notify/authRequired': 1, +}; + +// ---- Runtime filtering helpers ---------------------------------------------- + +/** + * Returns `true` if the given action type is known to a client at `clientVersion`. + */ +export function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { + return ACTION_INTRODUCED_IN[action.type] <= clientVersion; +} + +/** + * Returns `true` if the given notification type is known to a client at `clientVersion`. + */ +export function isNotificationKnownToVersion(notification: INotification, clientVersion: number): boolean { + return NOTIFICATION_INTRODUCED_IN[notification.type] <= clientVersion; +} diff --git a/src/vs/platform/agentHost/design.md b/src/vs/platform/agentHost/design.md new file mode 100644 index 0000000000000..9834fe8e44ebb --- /dev/null +++ b/src/vs/platform/agentHost/design.md @@ -0,0 +1,86 @@ +# Agent host design decisions + +> **Keep this document in sync with the code.** Any change to the agent-host protocol, tool rendering approach, or architectural boundaries must be reflected here. If you add a new `toolKind`, change how tool-specific data is populated, or modify the separation between agent-specific and generic code, update this document as part of the same change. + +Design decisions and principles for the agent-host feature. For process architecture and IPC details, see [architecture.md](architecture.md). For the client-server state protocol, see [protocol.md](protocol.md). + +## Agent-agnostic protocol + +**The protocol between the agent-host process and clients must remain agent-agnostic.** This is a hard rule. + +There are two protocol layers: + +1. **`IAgent` interface** (`common/agentService.ts`) - the internal interface that each agent backend (CopilotAgent, MockAgent) implements. It fires `IAgentProgressEvent`s (raw SDK events: `delta`, `tool_start`, `tool_complete`, etc.). This layer is agent-specific. + +2. **Sessions state protocol** (`common/state/`) - the client-facing protocol. The server maps raw `IAgentProgressEvent`s into state actions (`session/delta`, `session/toolStart`, etc.) via `agentEventMapper.ts`. Clients receive immutable state snapshots and action streams via JSON-RPC over WebSocket or MessagePort. **This layer is agent-agnostic.** + +All agent-specific logic -- translating tool names like `bash`/`view`/`grep` into display strings, extracting command lines from tool parameters, determining rendering hints like `toolKind: 'terminal'` -- lives in `copilotToolDisplay.ts` inside the agent-host process. These display-ready fields are carried on `IAgentToolStartEvent`/`IAgentToolCompleteEvent`, which `agentEventMapper.ts` then maps into `session/toolStart` and `session/toolComplete` state actions. + +Clients (renderers) never see agent-specific tool names. They consume `IToolCallState` and `ICompletedToolCall` from the session state tree, which carry generic display-ready fields (`displayName`, `invocationMessage`, `toolKind`, etc.). + +## Provider-agnostic renderer contributions + +The renderer contributions (`AgentHostSessionHandler`, `AgentHostSessionListController`, `AgentHostLanguageModelProvider`) are **completely generic**. They receive all provider-specific details via `IAgentHostSessionHandlerConfig`: + +```typescript +interface IAgentHostSessionHandlerConfig { + readonly provider: AgentProvider; // 'copilot' + readonly agentId: string; // e.g. 'agent-host' + readonly sessionType: string; // e.g. 'agent-host' + readonly fullName: string; // e.g. 'Agent Host - Copilot' + readonly description: string; +} +``` + +A single `AgentHostContribution` discovers agents via `listAgents()` and dynamically registers each one. Adding a new provider means adding a new `IAgent` implementation in the server process. No changes needed to the handler, list controller, or model provider. + +## State-based rendering + +The renderer subscribes to session state via `SessionClientState` (write-ahead reconciliation) and converts immutable state changes to `IChatProgress[]` via `stateToProgressAdapter.ts`. This adapter is the only place that inspects protocol state fields like `toolKind`: + +- **Shell commands** (`toolKind: 'terminal'`): Converted to `IChatTerminalToolInvocationData` with the command in a syntax-highlighted code block, output displayed below, and exit code for success/failure styling. +- **Everything else**: Converted to `ChatToolInvocation` using `invocationMessage` (while running) and `pastTenseMessage` (when complete). + +The adapter never checks tool names - it operates purely on the generic state fields. + +## Copilot SDK tool name mapping + +The Copilot CLI uses built-in tools. Tool names and parameter shapes are not typed in the SDK (`toolName` is `string`) - they come from the CLI server. The interfaces in `copilotToolDisplay.ts` are derived from observing actual CLI events. + +| SDK tool name | Display name | Rendering | +|---|---|---| +| `bash` | Bash | Terminal (`toolKind: 'terminal'`, language `shellscript`) | +| `powershell` | PowerShell | Terminal (`toolKind: 'terminal'`, language `powershell`) | +| `view` | View File | Progress message | +| `edit` | Edit File | Progress message | +| `write` | Write File | Progress message | +| `grep` | Search | Progress message | +| `glob` | Find Files | Progress message | +| `web_search` | Web Search | Progress message | + +This mapping lives in `copilotToolDisplay.ts` and is the only place that knows about Copilot-specific tool names. + +## Model ownership + +The SDK makes its own LM requests using the GitHub token. VS Code does not make direct LM calls for agent-host sessions. + +Each agent's models are published to root state via the `root/agentsChanged` action. The renderer's `AgentHostLanguageModelProvider` exposes these in the model picker. The selected model ID is passed to `createSession({ model })`. The `sendChatRequest` method throws - agent-host models aren't usable for direct LM calls, only for the agent loop. + +## Setting gate + +The entire feature is controlled by `chat.agentHost.enabled` (default `false`), defined as `AgentHostEnabledSettingId` in `agentService.ts`. When disabled: +- The main process does not spawn the agent host utility process +- The renderer does not connect via MessagePort +- No agents, sessions, or model providers are registered +- No agent-host entries appear in the UI + +## Multi-client state synchronization + +The sessions process uses a redux-like state model where all mutations flow through a discriminated union of actions processed by pure reducer functions. This design supports multiple connected clients seeing a synchronized view: + +- **Server-authoritative state**: The server holds the canonical state tree. Clients receive snapshots and incremental actions. +- **Write-ahead with reconciliation**: Clients optimistically apply their own actions locally (e.g., approving a permission, sending a message) and reconcile when the server echoes them back. Actions carry `(clientId, clientSeq)` tags for echo matching. +- **Lazy loading**: Clients connect with lightweight session metadata (enough for a sidebar list) and subscribe to full session state on demand. Large content (images, tool outputs) uses `ContentRef` placeholders fetched separately. +- **Forward-compatible versioning**: A single protocol version number maps to a `ProtocolCapabilities` object. Newer clients check capabilities before using features unavailable on older servers. + +Details and type definitions are in [protocol.md](protocol.md) and `common/state/`. diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts new file mode 100644 index 0000000000000..b414308ebe845 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; +import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { revive } from '../../../base/common/marshalling.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Renderer-side implementation of {@link IAgentHostService} that connects + * directly to the agent host utility process via MessagePort, bypassing + * the main process relay. Uses the same `getDelayedChannel` pattern as + * the pty host so the proxy is usable immediately while the port is acquired. + */ +class AgentHostServiceClient extends Disposable implements IAgentHostService { + declare readonly _serviceBrand: undefined; + + /** Unique identifier for this window, used in action envelope origin tracking. */ + readonly clientId = generateUuid(); + + private readonly _clientEventually = new DeferredPromise(); + private readonly _proxy: IAgentService; + + private readonly _onAgentHostExit = this._register(new Emitter()); + readonly onAgentHostExit = this._onAgentHostExit.event; + private readonly _onAgentHostStart = this._register(new Emitter()); + readonly onAgentHostStart = this._onAgentHostStart.event; + + private readonly _onDidAction = this._register(new Emitter()); + readonly onDidAction = this._onDidAction.event; + + private readonly _onDidNotification = this._register(new Emitter()); + readonly onDidNotification = this._onDidNotification.event; + + constructor( + @ILogService private readonly _logService: ILogService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + // Create a proxy backed by a delayed channel - usable immediately, + // calls queue until the MessagePort connection is established. + this._proxy = ProxyChannel.toService( + getDelayedChannel(this._clientEventually.p.then(client => client.getChannel(AgentHostIpcChannels.AgentHost))) + ); + + if (configurationService.getValue(AgentHostEnabledSettingId)) { + this._connect(); + } + } + + private async _connect(): Promise { + this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); + const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); + this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); + + const store = this._register(new DisposableStore()); + const client = store.add(new MessagePortClient(port, `agentHost:window`)); + this._clientEventually.complete(client); + + store.add(this._proxy.onDidAction(e => { + this._onDidAction.fire(revive(e)); + })); + store.add(this._proxy.onDidNotification(e => { + this._onDidNotification.fire(revive(e)); + })); + this._logService.info('[AgentHost:renderer] Direct MessagePort connection established'); + this._onAgentHostStart.fire(); + } + + // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- + + getResourceMetadata(): Promise { + return this._proxy.getResourceMetadata(); + } + authenticate(params: IAuthenticateParams): Promise { + return this._proxy.authenticate(params); + } + listAgents(): Promise { + return this._proxy.listAgents(); + } + refreshModels(): Promise { + return this._proxy.refreshModels(); + } + listSessions(): Promise { + return this._proxy.listSessions(); + } + createSession(config?: IAgentCreateSessionConfig): Promise { + return this._proxy.createSession(config); + } + disposeSession(session: URI): Promise { + return this._proxy.disposeSession(session); + } + shutdown(): Promise { + return this._proxy.shutdown(); + } + subscribe(resource: URI): Promise { + return this._proxy.subscribe(resource); + } + unsubscribe(resource: URI): void { + this._proxy.unsubscribe(resource); + } + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this._proxy.dispatchAction(action, clientId, clientSeq); + } + browseDirectory(uri: URI): Promise { + return this._proxy.browseDirectory(uri); + } + fetchContent(uri: URI): Promise { + return this._proxy.fetchContent(uri); + } + async restartAgentHost(): Promise { + // Restart is handled by the main process side + } +} + +registerSingleton(IAgentHostService, AgentHostServiceClient, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts new file mode 100644 index 0000000000000..de400d715319f --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Protocol client for communicating with a remote agent host process. +// Wraps WebSocketClientTransport and SessionClientState to provide a +// higher-level API matching IAgentService. + +import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { hasKey } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; +import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; +import { isJsonRpcNotification, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import type { ISessionSummary } from '../common/state/sessionState.js'; +import { WebSocketClientTransport } from './webSocketClientTransport.js'; + +/** + * A protocol-level client for a single remote agent host connection. + * Manages the WebSocket transport, handshake, subscriptions, action dispatch, + * and command/response correlation. + * + * Implements {@link IAgentConnection} so consumers can program against + * a single interface regardless of whether the agent host is local or remote. + */ +export class RemoteAgentHostProtocolClient extends Disposable implements IAgentConnection { + + declare readonly _serviceBrand: undefined; + + private readonly _clientId = generateUuid(); + private readonly _transport: WebSocketClientTransport; + private _serverSeq = 0; + private _nextClientSeq = 1; + private _defaultDirectory: string | undefined; + + private readonly _onDidAction = this._register(new Emitter()); + readonly onDidAction = this._onDidAction.event; + + private readonly _onDidNotification = this._register(new Emitter()); + readonly onDidNotification = this._onDidNotification.event; + + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + /** Pending JSON-RPC requests keyed by request id. */ + private readonly _pendingRequests = new Map>(); + private _nextRequestId = 1; + + get clientId(): string { + return this._clientId; + } + + get address(): string { + return this._transport['_address']; + } + + get defaultDirectory(): string | undefined { + return this._defaultDirectory; + } + + constructor( + address: string, + connectionToken: string | undefined, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._transport = this._register(new WebSocketClientTransport(address, connectionToken)); + this._register(this._transport.onMessage(msg => this._handleMessage(msg))); + this._register(this._transport.onClose(() => this._onDidClose.fire())); + } + + /** + * Connect to the remote agent host and perform the protocol handshake. + */ + async connect(): Promise { + await this._transport.connect(); + + const result = await this._sendRequest('initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId: this._clientId, + }); + this._serverSeq = result.serverSeq; + // defaultDirectory arrives from the protocol as either a URI string + // (e.g. "file:///Users/roblou") or a serialized URI object + // ({ scheme, path, ... }). Extract just the filesystem path. + if (result.defaultDirectory) { + const dir = result.defaultDirectory; + if (typeof dir === 'string') { + this._defaultDirectory = URI.parse(dir).path; + } else { + this._defaultDirectory = URI.revive(dir).path; + } + } + } + + /** + * Subscribe to state at a URI. Returns the current state snapshot. + */ + async subscribe(resource: URI): Promise { + const result = await this._sendRequest('subscribe', { resource: resource.toString() }); + return result.snapshot; + } + + /** + * Unsubscribe from state at a URI. + */ + unsubscribe(resource: URI): void { + this._sendNotification('unsubscribe', { resource: resource.toString() }); + } + + /** + * Dispatch a client action to the server. Returns the clientSeq used. + */ + dispatchAction(action: ISessionAction, _clientId: string, clientSeq: number): void { + this._sendNotification('dispatchAction', { clientSeq, action }); + } + + /** + * Create a new session on the remote agent host. + */ + async createSession(config?: IAgentCreateSessionConfig): Promise { + const provider = config?.provider ?? 'copilot'; + const session = AgentSession.uri(provider, generateUuid()); + await this._sendRequest('createSession', { + session: session.toString(), + provider, + model: config?.model, + workingDirectory: config?.workingDirectory, + }); + return session; + } + + /** + * Retrieve the server's resource metadata describing auth requirements. + */ + async getResourceMetadata(): Promise { + return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; + } + + /** + * Authenticate with the remote agent host using a specific scheme. + */ + async authenticate(params: IAuthenticateParams): Promise { + return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; + } + + /** + * Refresh the model list from all providers on the remote host. + */ + async refreshModels(): Promise { + await this._sendExtensionRequest('refreshModels'); + } + + /** + * Discover available agent backends from the remote host. + */ + async listAgents(): Promise { + return await this._sendExtensionRequest('listAgents') as IAgentDescriptor[]; + } + + /** + * Gracefully shut down all sessions on the remote host. + */ + async shutdown(): Promise { + await this._sendExtensionRequest('shutdown'); + } + + /** + * Dispose a session on the remote agent host. + */ + async disposeSession(session: URI): Promise { + await this._sendRequest('disposeSession', { session: session.toString() }); + } + + /** + * List all sessions from the remote agent host. + */ + async listSessions(): Promise { + const result = await this._sendRequest('listSessions', {}); + return result.items.map((s: ISessionSummary) => ({ + session: URI.parse(s.resource), + startTime: s.createdAt, + modifiedTime: s.modifiedAt, + summary: s.title, + workingDirectory: typeof s.workingDirectory === 'string' ? s.workingDirectory : undefined, + })); + } + + /** + * List the contents of a directory on the remote host's filesystem. + */ + async browseDirectory(uri: URI): Promise { + return await this._sendRequest('browseDirectory', { uri: uri.toString() }); + } + + /** + * Fetch the content of a file on the remote host's filesystem. + */ + async fetchContent(uri: URI): Promise { + return this._sendRequest('fetchContent', { uri: uri.toString() }); + } + + private _handleMessage(msg: IProtocolMessage): void { + if (isJsonRpcResponse(msg)) { + const pending = this._pendingRequests.get(msg.id); + if (pending) { + this._pendingRequests.delete(msg.id); + if (hasKey(msg, { error: true })) { + this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); + pending.error(new Error(msg.error.message)); + } else { + pending.complete(msg.result); + } + } else { + this._logService.warn(`[RemoteAgentHostProtocol] Received response for unknown request id ${msg.id}`); + } + } else if (isJsonRpcNotification(msg)) { + switch (msg.method) { + case 'action': { + // Protocol envelope → VS Code envelope (superset of action types) + const envelope = msg.params as unknown as IActionEnvelope; + this._serverSeq = Math.max(this._serverSeq, envelope.serverSeq); + this._onDidAction.fire(envelope); + break; + } + case 'notification': { + const notification = msg.params.notification as unknown as INotification; + this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${notification.type}`); + this._onDidNotification.fire(notification); + break; + } + default: + this._logService.trace(`[RemoteAgentHostProtocol] Unhandled method: ${msg.method}`); + break; + } + } else { + this._logService.warn(`[RemoteAgentHostProtocol] Unrecognized message:`, JSON.stringify(msg)); + } + } + + /** Send a typed JSON-RPC notification for a protocol-defined method. */ + private _sendNotification(method: M, params: IClientNotificationMap[M]['params']): void { + // Generic M can't satisfy the distributive IAhpNotification union directly + // eslint-disable-next-line local/code-no-dangerous-type-assertions + this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage); + } + + /** Send a typed JSON-RPC request for a protocol-defined method. */ + private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { + const id = this._nextRequestId++; + const deferred = new DeferredPromise(); + this._pendingRequests.set(id, deferred); + // Generic M can't satisfy the distributive IAhpRequest union directly + // eslint-disable-next-line local/code-no-dangerous-type-assertions + this._transport.send({ jsonrpc: '2.0' as const, id, method, params } as IProtocolMessage); + return deferred.p as Promise; + } + + /** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */ + private _sendExtensionRequest(method: string, params?: unknown): Promise { + const id = this._nextRequestId++; + const deferred = new DeferredPromise(); + this._pendingRequests.set(id, deferred); + // Cast: extension methods aren't in the typed protocol maps yet + // eslint-disable-next-line local/code-no-dangerous-type-assertions + this._transport.send({ jsonrpc: '2.0', id, method, params } as unknown as IJsonRpcResponse); + return deferred.p; + } + + /** + * Get the next client sequence number for optimistic dispatch. + */ + nextClientSeq(): number { + return this._nextClientSeq++; + } +} diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts new file mode 100644 index 0000000000000..38714cb1d9d1e --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostService.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; +import { RemoteAgentHostService } from './remoteAgentHostServiceImpl.js'; + +registerSingleton(IRemoteAgentHostService, RemoteAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts new file mode 100644 index 0000000000000..773f0dfbe1c8a --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts @@ -0,0 +1,336 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Service implementation that manages WebSocket connections to remote agent +// host processes. Reads addresses from the `chat.remoteAgentHosts` setting +// and maintains connections, reconnecting as the setting changes. + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; +import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; + +import type { IAgentConnection } from '../common/agentService.js'; +import { + IRemoteAgentHostService, + RemoteAgentHostsEnabledSettingId, + RemoteAgentHostsSettingId, + type IRemoteAgentHostConnectionInfo, + type IRemoteAgentHostEntry, +} from '../common/remoteAgentHostService.js'; +import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; +import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; + +/** Tracks a single remote connection through its lifecycle. */ +interface IConnectionEntry { + readonly store: DisposableStore; + readonly client: RemoteAgentHostProtocolClient; + connected: boolean; +} + +export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { + private static readonly ConnectionWaitTimeout = 10000; + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConnections = this._register(new Emitter()); + readonly onDidChangeConnections = this._onDidChangeConnections.event; + + private readonly _entries = new Map(); + private readonly _names = new Map(); + private readonly _pendingConnectionWaits = new Map>(); + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // React to setting changes + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + this._reconcileConnections(); + } + })); + + // Initial connection + this._reconcileConnections(); + } + + get connections(): readonly IRemoteAgentHostConnectionInfo[] { + const result: IRemoteAgentHostConnectionInfo[] = []; + for (const [address, entry] of this._entries) { + if (entry.connected) { + result.push({ + address, + name: this._names.get(address) ?? address, + clientId: entry.client.clientId, + defaultDirectory: entry.client.defaultDirectory, + }); + } + } + return result; + } + + get configuredEntries(): readonly IRemoteAgentHostEntry[] { + return this._getConfiguredEntries().map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) })); + } + + getConnection(address: string): IAgentConnection | undefined { + const normalized = normalizeRemoteAgentHostAddress(address); + const entry = this._entries.get(normalized); + return entry?.connected ? entry.client : undefined; + } + + async addRemoteAgentHost(input: IRemoteAgentHostEntry): Promise { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + throw new Error('Remote agent host connections are not enabled.'); + } + + const entry: IRemoteAgentHostEntry = { ...input, address: normalizeRemoteAgentHostAddress(input.address) }; + const existingConnection = this._getConnectionInfo(entry.address); + await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); + + if (existingConnection) { + return { + ...existingConnection, + name: entry.name, + }; + } + + const connectedConnection = this._getConnectionInfo(entry.address); + if (connectedConnection) { + return connectedConnection; + } + + const wait = this._getOrCreateConnectionWait(entry.address); + const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { + this._pendingConnectionWaits.delete(entry.address); + }); + if (!connection) { + throw new Error(`Timed out connecting to ${entry.address}`); + } + + return connection; + } + + async removeRemoteAgentHost(address: string): Promise { + const normalized = normalizeRemoteAgentHostAddress(address); + // This setting is only used in the sessions app (user scope), so we + // don't need to inspect per-scope values like _upsertConfiguredEntry does. + const entries = this._getConfiguredEntries().filter( + e => normalizeRemoteAgentHostAddress(e.address) !== normalized + ); + await this._storeConfiguredEntries(entries); + + // Eagerly clear in-memory state so the UI updates immediately + // (the config change listener will reconcile, but this is instant). + this._names.delete(normalized); + this._removeConnection(normalized); + } + + private _removeConnection(address: string): void { + const entry = this._entries.get(address); + if (entry) { + this._entries.delete(address); + entry.store.dispose(); + this._rejectPendingConnectionWait(address, new Error(`Connection closed: ${address}`)); + this._onDidChangeConnections.fire(); + } + } + + private _reconcileConnections(): void { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + // Disconnect all when disabled + for (const address of [...this._entries.keys()]) { + this._removeConnection(address); + } + this._names.clear(); + return; + } + + const rawEntries: IRemoteAgentHostEntry[] = this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []; + const entries = rawEntries.map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) })); + const desired = new Set(entries.map(e => e.address)); + + this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`); + + // Update name map and detect name changes for existing connections + let namesChanged = false; + const oldNames = new Map(this._names); + this._names.clear(); + for (const entry of entries) { + this._names.set(entry.address, entry.name); + if (this._entries.has(entry.address) && oldNames.get(entry.address) !== entry.name) { + namesChanged = true; + } + } + + // Remove connections no longer in the setting + for (const address of [...this._entries.keys()]) { + if (!desired.has(address)) { + this._logService.info(`[RemoteAgentHost] Disconnecting from ${address}`); + this._removeConnection(address); + } + } + + // Add new connections + for (const entry of entries) { + if (!this._entries.has(entry.address)) { + this._connectTo(entry.address, entry.connectionToken); + } + } + + // If only names changed (no add/remove), notify so the UI updates + if (namesChanged) { + this._onDidChangeConnections.fire(); + } + } + + private _connectTo(address: string, connectionToken?: string): void { + const store = new DisposableStore(); + const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, connectionToken)); + const entry: IConnectionEntry = { store, client, connected: false }; + this._entries.set(address, entry); + + // Guard removal against stale callbacks: only remove if the + // current entry for this address is still the one we created. + const guardedRemove = () => { + if (this._entries.get(address) === entry) { + this._removeConnection(address); + } + }; + + store.add(client.onDidClose(() => { + this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); + guardedRemove(); + })); + + this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); + client.connect().then(() => { + if (store.isDisposed) { + return; // removed before connect resolved + } + this._logService.info(`[RemoteAgentHost] Connected to ${address}`); + entry.connected = true; + this._resolvePendingConnectionWait(address); + this._onDidChangeConnections.fire(); + }).catch(err => { + this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}`, err); + this._rejectPendingConnectionWait(address, err); + guardedRemove(); + }); + } + + private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { + return this.connections.find(connection => connection.address === address); + } + + private _getConfiguredEntries(): IRemoteAgentHostEntry[] { + return this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []; + } + + private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { + // Read from the same scope we'll write to, so we don't accidentally + // merge entries from an overriding scope (e.g. workspace) into the + // user scope and then lose them on the next read. + const target = this._getConfigurationTarget(); + const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); + let configuredEntries: readonly IRemoteAgentHostEntry[]; + switch (target) { + case ConfigurationTarget.USER_LOCAL: + configuredEntries = inspected.userLocalValue ?? []; + break; + case ConfigurationTarget.USER_REMOTE: + configuredEntries = inspected.userRemoteValue ?? []; + break; + default: + configuredEntries = inspected.userValue ?? []; + break; + } + + const normalizedAddress = normalizeRemoteAgentHostAddress(entry.address); + const existingIndex = configuredEntries.findIndex(configuredEntry => normalizeRemoteAgentHostAddress(configuredEntry.address) === normalizedAddress); + if (existingIndex === -1) { + return [...configuredEntries, entry]; + } + + return configuredEntries.map((configuredEntry, index) => index === existingIndex ? entry : configuredEntry); + } + + private _getConfigurationTarget(): ConfigurationTarget { + const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); + if (inspected.userLocalValue !== undefined) { + return ConfigurationTarget.USER_LOCAL; + } + if (inspected.userRemoteValue !== undefined) { + return ConfigurationTarget.USER_REMOTE; + } + if (inspected.userValue !== undefined) { + return ConfigurationTarget.USER; + } + return ConfigurationTarget.USER; + } + + private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise { + await this._configurationService.updateValue(RemoteAgentHostsSettingId, entries, this._getConfigurationTarget()); + } + + private _getOrCreateConnectionWait(address: string): DeferredPromise { + let wait = this._pendingConnectionWaits.get(address); + if (wait) { + return wait; + } + + // If the connection is already available (fast connect resolved before + // the caller called us), return an immediately-completed wait. + const existingConnection = this._getConnectionInfo(address); + if (existingConnection) { + const immediateWait = new DeferredPromise(); + immediateWait.complete(existingConnection); + return immediateWait; + } + + wait = new DeferredPromise(); + this._pendingConnectionWaits.set(address, wait); + return wait; + } + + private _resolvePendingConnectionWait(address: string): void { + const wait = this._pendingConnectionWaits.get(address); + const connection = this._getConnectionInfo(address); + if (!wait || !connection) { + return; + } + + this._pendingConnectionWaits.delete(address); + void wait.complete(connection); + } + + private _rejectPendingConnectionWait(address: string, err: unknown): void { + const wait = this._pendingConnectionWaits.get(address); + if (!wait) { + return; + } + + this._pendingConnectionWaits.delete(address); + void wait.error(err); + } + + override dispose(): void { + for (const [address, wait] of this._pendingConnectionWaits) { + void wait.error(new Error(`Remote agent host service disposed before connecting to ${address}`)); + } + this._pendingConnectionWaits.clear(); + for (const entry of this._entries.values()) { + entry.store.dispose(); + } + this._entries.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts new file mode 100644 index 0000000000000..2a8ce7370a2be --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/webSocketClientTransport.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// WebSocket client transport for connecting to remote agent host processes. +// Uses plain JSON serialization — URIs are string-typed in the protocol. + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { connectionTokenQueryName } from '../../../base/common/network.js'; +import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IProtocolTransport } from '../common/state/sessionTransport.js'; + +// ---- Client transport ------------------------------------------------------- + +/** + * A WebSocket client transport that connects to a remote agent host server. + * Uses the native browser WebSocket API (available in Electron renderer). + * Implements {@link IProtocolTransport} with JSON serialization and URI revival. + */ +export class WebSocketClientTransport extends Disposable implements IProtocolTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + private readonly _onOpen = this._register(new Emitter()); + readonly onOpen = this._onOpen.event; + + private _ws: WebSocket | undefined; + + get isOpen(): boolean { + return this._ws?.readyState === WebSocket.OPEN; + } + + constructor( + private readonly _address: string, + private readonly _connectionToken?: string, + ) { + super(); + } + + /** + * Initiate the WebSocket connection. Resolves when the connection + * is open, or rejects on error/timeout. + */ + connect(): Promise { + return new Promise((resolve, reject) => { + if (this._store.isDisposed) { + reject(new Error('Transport is disposed')); + return; + } + + let url = this._address.startsWith('ws://') || this._address.startsWith('wss://') + ? this._address + : `ws://${this._address}`; + + if (this._connectionToken) { + const separator = url.includes('?') ? '&' : '?'; + url += `${separator}${connectionTokenQueryName}=${encodeURIComponent(this._connectionToken)}`; + } + + const ws = new WebSocket(url); + this._ws = ws; + + const onOpen = () => { + cleanup(); + this._onOpen.fire(); + resolve(); + }; + + const onError = () => { + cleanup(); + reject(new Error(`WebSocket connection failed: ${this._address}`)); + }; + + const onClose = () => { + cleanup(); + reject(new Error(`WebSocket closed before connection was established: ${this._address}`)); + }; + + const cleanup = () => { + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + ws.removeEventListener('close', onClose); + }; + + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + ws.addEventListener('close', onClose); + + // Wire up long-lived listeners after connection + ws.addEventListener('message', (event: MessageEvent) => { + try { + const text = typeof event.data === 'string' ? event.data : ''; + const message = JSON.parse(text) as IProtocolMessage; + this._onMessage.fire(message); + } catch { + // Malformed message - drop. + } + }); + + ws.addEventListener('close', () => { + this._onClose.fire(); + }); + + ws.addEventListener('error', () => { + // Error always precedes close - closing is handled in the close handler. + this._onClose.fire(); + }); + }); + } + + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify(message)); + } + } + + override dispose(): void { + this._ws?.close(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts new file mode 100644 index 0000000000000..5abe4cd03aa88 --- /dev/null +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { deepClone } from '../../../base/common/objects.js'; +import { IpcMainEvent } from 'electron'; +import { validatedIpcMain } from '../../../base/parts/ipc/electron-main/ipcMain.js'; +import { Client as MessagePortClient } from '../../../base/parts/ipc/electron-main/ipc.mp.js'; +import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; +import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; +import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; +import { ILogService } from '../../log/common/log.js'; +import { Schemas } from '../../../base/common/network.js'; +import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; +import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; + +export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { + + private utilityProcess: UtilityProcess | undefined = undefined; + + private readonly _onRequestConnection = this._register(new Emitter()); + readonly onRequestConnection = this._onRequestConnection.event; + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown = this._onWillShutdown.event; + + constructor( + @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, + @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._lifecycleMainService.onWillShutdown(() => this._onWillShutdown.fire())); + + // Listen for new windows to establish a direct MessagePort connection to the agent host + const onWindowConnection = (e: IpcMainEvent, nonce: string) => this._onWindowConnection(e, nonce); + validatedIpcMain.on('vscode:createAgentHostMessageChannel', onWindowConnection); + this._register(toDisposable(() => { + validatedIpcMain.removeListener('vscode:createAgentHostMessageChannel', onWindowConnection); + })); + } + + start(): IAgentHostConnection { + this.utilityProcess = new UtilityProcess(this._logService, NullTelemetryService, this._lifecycleMainService); + + const inspectParams = parseAgentHostDebugPort(this._environmentMainService.args, this._environmentMainService.isBuilt); + const execArgv = inspectParams.port ? [ + '--nolazy', + `--inspect${inspectParams.break ? '-brk' : ''}=${inspectParams.port}` + ] : undefined; + + this.utilityProcess.start({ + type: 'agentHost', + name: 'agent-host', + entryPoint: 'vs/platform/agentHost/node/agentHostMain', + execArgv, + args: ['--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath], + env: { + ...deepClone(process.env), + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + } + }); + + const port = this.utilityProcess.connect(); + const client = new MessagePortClient(port, 'agentHost'); + + const store = new DisposableStore(); + store.add(client); + store.add(this.utilityProcess.onStderr(data => { + if (this._isExpectedStderr(data)) { + return; + } + this._logService.error(`[AgentHost:stderr] ${data}`); + })); + store.add(toDisposable(() => { + this.utilityProcess?.kill(); + this.utilityProcess?.dispose(); + this.utilityProcess = undefined; + })); + + return { + client, + store, + onDidProcessExit: this.utilityProcess.onExit, + }; + } + + private _onWindowConnection(e: IpcMainEvent, nonce: string): void { + this._onRequestConnection.fire(); + + if (!this.utilityProcess) { + this._logService.error('AgentHostStarter: cannot create window connection, agent host process is not running'); + return; + } + + const port = this.utilityProcess.connect(); + + if (e.sender.isDestroyed()) { + port.close(); + return; + } + + e.sender.postMessage('vscode:createAgentHostMessageChannelResult', nonce, [port]); + } + + private static readonly _expectedStderrPatterns = [ + 'Most NODE_OPTIONs are not supported in packaged apps', + 'Debugger listening on ws://', + 'For help, see: https://nodejs.org/en/docs/inspector', + 'ExperimentalWarning: SQLite is an experimental feature', + ]; + + private _isExpectedStderr(data: string): boolean { + return ElectronAgentHostStarter._expectedStderrPatterns.some(pattern => data.includes(pattern)); + } +} diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts new file mode 100644 index 0000000000000..91241632d098a --- /dev/null +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import type { + IAgentDeltaEvent, + IAgentErrorEvent, + IAgentProgressEvent, + IAgentReasoningEvent, + IAgentTitleChangedEvent, + IAgentToolCompleteEvent, + IAgentToolStartEvent, + IAgentUsageEvent +} from '../common/agentService.js'; +import { + ActionType, + type ISessionAction, + type ISessionErrorAction, + type ITitleChangedAction, + type IToolCallCompleteAction, + type IToolCallReadyAction, + type IToolCallStartAction, + type ITurnCompleteAction, + type IUsageAction +} from '../common/state/sessionActions.js'; +import { ResponsePartKind, ToolCallConfirmationReason, type URI } from '../common/state/sessionState.js'; + +/** + * Stateful mapper that tracks the "current" markdown and reasoning response + * parts per session/turn so that streaming deltas can be routed to the correct + * part via `partId`. + * + * Call {@link reset} when a new turn starts to clear tracked part IDs. + */ +export class AgentEventMapper { + /** Current markdown part ID per session. Reset on each new turn. */ + private readonly _currentMarkdownPartId = new Map(); + /** Current reasoning part ID per session. Reset on each new turn. */ + private readonly _currentReasoningPartId = new Map(); + + /** + * Resets tracked part IDs for a session (call when a new turn starts). + */ + reset(session: string): void { + this._currentMarkdownPartId.delete(session); + this._currentReasoningPartId.delete(session); + } + + /** + * Maps a flat {@link IAgentProgressEvent} from the agent host into + * protocol {@link ISessionAction}(s) suitable for dispatch to the reducer. + * + * Returns `undefined` for events that have no corresponding action. + * May return an array when a single SDK event maps to multiple protocol actions. + */ + mapProgressEventToActions(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | ISessionAction[] | undefined { + switch (event.type) { + case 'delta': { + const e = event as IAgentDeltaEvent; + const existingPartId = this._currentMarkdownPartId.get(session); + if (!existingPartId) { + // Create a new markdown part with the content directly + const partId = generateUuid(); + this._currentMarkdownPartId.set(session, partId); + return { + type: ActionType.SessionResponsePart, + session, + turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content: e.content }, + }; + } + return { + type: ActionType.SessionDelta, + session, + turnId, + partId: existingPartId, + content: e.content, + }; + } + + case 'tool_start': { + // A new tool call invalidates the current markdown part so the + // next text delta creates a fresh part after the tool call. + this._currentMarkdownPartId.delete(session); + + // The Copilot SDK provides full parameters at tool_start time. + // We emit both toolCallStart (streaming → created) and toolCallReady + // (params complete → running with auto-confirm) as a pair. + const e = event as IAgentToolStartEvent; + const startAction: IToolCallStartAction = { + type: ActionType.SessionToolCallStart, + session, + turnId, + toolCallId: e.toolCallId, + toolName: e.toolName, + displayName: e.displayName, + _meta: { toolKind: e.toolKind, language: e.language }, + }; + const readyAction: IToolCallReadyAction = { + type: ActionType.SessionToolCallReady, + session, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmed: ToolCallConfirmationReason.NotNeeded, + }; + return [startAction, readyAction]; + } + + case 'tool_ready': { + // A running tool requires re-confirmation (e.g. mid-execution permission). + // Emit toolCallReady WITHOUT confirmed, which transitions + // Running → PendingConfirmation in the reducer. + const e = event; + return { + type: ActionType.SessionToolCallReady, + session, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmationTitle: e.confirmationTitle, + } satisfies IToolCallReadyAction; + } + + case 'tool_complete': { + const e = event as IAgentToolCompleteEvent; + return { + type: ActionType.SessionToolCallComplete, + session, + turnId, + toolCallId: e.toolCallId, + result: e.result, + } satisfies IToolCallCompleteAction; + } + + case 'idle': + return { + type: ActionType.SessionTurnComplete, + session, + turnId, + } satisfies ITurnCompleteAction; + + case 'error': { + const e = event as IAgentErrorEvent; + return { + type: ActionType.SessionError, + session, + turnId, + error: { + errorType: e.errorType, + message: e.message, + stack: e.stack, + }, + } satisfies ISessionErrorAction; + } + + case 'usage': { + const e = event as IAgentUsageEvent; + return { + type: ActionType.SessionUsage, + session, + turnId, + usage: { + inputTokens: e.inputTokens, + outputTokens: e.outputTokens, + model: e.model, + cacheReadTokens: e.cacheReadTokens, + }, + } satisfies IUsageAction; + } + + case 'title_changed': + return { + type: ActionType.SessionTitleChanged, + session, + title: (event as IAgentTitleChangedEvent).title, + } satisfies ITitleChangedAction; + + case 'reasoning': { + const e = event as IAgentReasoningEvent; + const existingPartId = this._currentReasoningPartId.get(session); + if (!existingPartId) { + // Create a new reasoning part with the content directly + const partId = generateUuid(); + this._currentReasoningPartId.set(session, partId); + return { + type: ActionType.SessionResponsePart, + session, + turnId, + part: { kind: ResponsePartKind.Reasoning, id: partId, content: e.content }, + }; + } + return { + type: ActionType.SessionReasoning, + session, + turnId, + partId: existingPartId, + content: e.content, + }; + } + + case 'message': + return undefined; + + default: + return undefined; + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts new file mode 100644 index 0000000000000..7df7461682c7d --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; +import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; +import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; +import { Emitter } from '../../../base/common/event.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import * as os from 'os'; +import { AgentHostIpcChannels, AgentSession } from '../common/agentService.js'; +import { SessionStatus } from '../common/state/sessionState.js'; +import { AgentService } from './agentService.js'; +import { CopilotAgent } from './copilot/copilotAgent.js'; +import { ProtocolServerHandler, type IProtocolSideEffectHandler } from './protocolServerHandler.js'; +import { WebSocketProtocolServer } from './webSocketTransport.js'; +import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; +import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; +import { getLogLevel, ILogService } from '../../log/common/log.js'; +import { LogService } from '../../log/common/logService.js'; +import { LoggerService } from '../../log/node/loggerService.js'; +import { LoggerChannel } from '../../log/common/logIpc.js'; +import { DefaultURITransformer } from '../../../base/common/uriIpc.js'; +import product from '../../product/common/product.js'; +import { IProductService } from '../../product/common/productService.js'; +import { localize } from '../../../nls.js'; +import { FileService } from '../../files/common/fileService.js'; +import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; +import { Schemas } from '../../../base/common/network.js'; +import { SessionDataService } from './sessionDataService.js'; + +// Entry point for the agent host utility process. +// Sets up IPC, logging, and registers agent providers (Copilot). +// When VSCODE_AGENT_HOST_PORT or VSCODE_AGENT_HOST_SOCKET_PATH env vars +// are set, also starts a WebSocket server for external clients. + +startAgentHost(); + +function startAgentHost(): void { + // Setup RPC - supports both Electron utility process and Node child process + let server: ChildProcessServer | UtilityProcessServer; + if (isUtilityProcess(process)) { + server = new UtilityProcessServer(); + } else { + server = new ChildProcessServer(AgentHostIpcChannels.AgentHost); + } + + const disposables = new DisposableStore(); + + // Services + const productService: IProductService = { _serviceBrand: undefined, ...product }; + const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService); + const loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); + server.registerChannel(AgentHostIpcChannels.Logger, new LoggerChannel(loggerService, () => DefaultURITransformer)); + const logger = loggerService.createLogger('agenthost', { name: localize('agentHost', "Agent Host") }); + const logService = new LogService(logger); + logService.info('Agent Host process started successfully'); + + // File service + const fileService = disposables.add(new FileService(logService)); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + + // Session data service + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + + // Create the real service implementation that lives in this process + let agentService: AgentService; + try { + agentService = new AgentService(logService, fileService, sessionDataService); + agentService.registerProvider(new CopilotAgent(logService, fileService, sessionDataService)); + } catch (err) { + logService.error('Failed to create AgentService', err); + throw err; + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); + server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); + + // Expose the WebSocket client connection count to the parent process via IPC. + // This is NOT part of the agent host protocol -- it is only used by the + // server process to manage the agent host process lifetime. + const connectionCountEmitter = disposables.add(new Emitter()); + const connectionTrackerChannel = ProxyChannel.fromService( + { onDidChangeConnectionCount: connectionCountEmitter.event }, + disposables, + ); + server.registerChannel(AgentHostIpcChannels.ConnectionTracker, connectionTrackerChannel); + + // Start WebSocket server for external clients if configured + startWebSocketServer(agentService, logService, disposables, count => connectionCountEmitter.fire(count)).catch(err => { + logService.error('Failed to start WebSocket server', err); + }); + + process.once('exit', () => { + agentService.dispose(); + logService.dispose(); + disposables.dispose(); + }); +} + +/** + * When the parent process passes WebSocket configuration via environment + * variables, start a protocol server that external clients can connect to. + * This reuses the same {@link AgentService} and {@link SessionStateManager} + * that the IPC channel uses, so both IPC and WebSocket clients share state. + */ +async function startWebSocketServer(agentService: AgentService, logService: ILogService, disposables: DisposableStore, onConnectionCountChanged: (count: number) => void): Promise { + const port = process.env['VSCODE_AGENT_HOST_PORT']; + const socketPath = process.env['VSCODE_AGENT_HOST_SOCKET_PATH']; + + if (!port && !socketPath) { + return; + } + + const connectionToken = process.env['VSCODE_AGENT_HOST_CONNECTION_TOKEN']; + const host = process.env['VSCODE_AGENT_HOST_HOST'] || 'localhost'; + + const wsServer = disposables.add(await WebSocketProtocolServer.create( + socketPath + ? { + socketPath, + connectionTokenValidate: connectionToken + ? (token) => token === connectionToken + : undefined, + } + : { + port: parseInt(port!, 10), + host, + connectionTokenValidate: connectionToken + ? (token) => token === connectionToken + : undefined, + }, + logService, + )); + + // Create a side-effect handler that delegates to AgentService + const sideEffects: IProtocolSideEffectHandler = { + handleAction(action) { + agentService.dispatchAction(action, 'ws-server', 0); + }, + async handleCreateSession(command) { + await agentService.createSession({ + provider: command.provider, + model: command.model, + workingDirectory: command.workingDirectory, + session: URI.parse(command.session), + }); + }, + handleDisposeSession(session) { + agentService.disposeSession(URI.parse(session)); + }, + async handleListSessions() { + const sessions = await agentService.listSessions(); + return sessions.map(s => ({ + resource: s.session.toString(), + provider: AgentSession.provider(s.session) ?? 'copilot', + title: s.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + workingDirectory: s.workingDirectory, + })); + }, + + handleGetResourceMetadata() { + return agentService.getResourceMetadataSync(); + }, + async handleAuthenticate(params) { + return agentService.authenticate(params); + }, + handleBrowseDirectory(uri) { + return agentService.browseDirectory(URI.parse(uri)); + }, + async handleRestoreSession(session) { + return agentService.restoreSession(URI.parse(session)); + }, + handleFetchContent(uri) { + return agentService.fetchContent(URI.parse(uri)); + }, + getDefaultDirectory() { + return URI.file(os.homedir()).toString(); + }, + }; + + const protocolHandler = disposables.add(new ProtocolServerHandler(agentService.stateManager, wsServer, sideEffects, logService)); + disposables.add(protocolHandler.onDidChangeConnectionCount(onConnectionCountChanged)); + + const listenTarget = socketPath ?? `${host}:${port}`; + logService.info(`[AgentHost] WebSocket server listening on ${listenTarget}`); + // Do not change this line. The CLI looks for this in the output. + console.log(`Agent host server listening on ${listenTarget}`); +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts new file mode 100644 index 0000000000000..8622b1c244b66 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Standalone agent host server with WebSocket protocol transport. +// Start with: node out/vs/platform/agentHost/node/agentHostServerMain.js [--port ] [--connection-token ] [--connection-token-file ] [--without-connection-token] [--enable-mock-agent] [--quiet] [--log ] + +import { fileURLToPath } from 'url'; + +// This standalone process isn't bootstrapped via bootstrap-esm.ts, so we must +// set _VSCODE_FILE_ROOT ourselves so that FileAccess can resolve module paths. +// This file lives at out/vs/platform/agentHost/node/ - the root is `out/`. +globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)); + +import * as fs from 'fs'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; +import { NativeEnvironmentService } from '../../environment/node/environmentService.js'; +import { INativeEnvironmentService } from '../../environment/common/environment.js'; +import { parseArgs, OPTIONS } from '../../environment/node/argv.js'; +import { getLogLevel, ILogService, NullLogService } from '../../log/common/log.js'; +import { LogService } from '../../log/common/logService.js'; +import { LoggerService } from '../../log/node/loggerService.js'; +import product from '../../product/common/product.js'; +import { IProductService } from '../../product/common/productService.js'; +import { InstantiationService } from '../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { CopilotAgent } from './copilot/copilotAgent.js'; +import { AgentSession, type AgentProvider, type IAgent } from '../common/agentService.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { SessionStateManager } from './sessionStateManager.js'; +import { WebSocketProtocolServer } from './webSocketTransport.js'; +import { ProtocolServerHandler } from './protocolServerHandler.js'; +import { FileService } from '../../files/common/fileService.js'; +import { IFileService } from '../../files/common/files.js'; +import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; +import { Schemas } from '../../../base/common/network.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { SessionDataService } from './sessionDataService.js'; + +/** Log to stderr so messages appear in the terminal alongside the process. */ +function log(msg: string): void { + process.stderr.write(`[AgentHostServer] ${msg}\n`); +} + +// ---- Options ---------------------------------------------------------------- + +const connectionTokenRegex = /^[0-9A-Za-z_-]+$/; + +interface IServerOptions { + readonly port: number; + readonly enableMockAgent: boolean; + readonly quiet: boolean; + /** Connection token string, or `undefined` when `--without-connection-token`. */ + readonly connectionToken: string | undefined; +} + +function parseServerOptions(): IServerOptions { + const argv = process.argv.slice(2); + const envPort = parseInt(process.env['VSCODE_AGENT_HOST_PORT'] ?? '8081', 10); + const portIdx = argv.indexOf('--port'); + const port = portIdx >= 0 ? parseInt(argv[portIdx + 1], 10) : envPort; + const enableMockAgent = argv.includes('--enable-mock-agent'); + const quiet = argv.includes('--quiet'); + + // Connection token + const withoutConnectionToken = argv.includes('--without-connection-token'); + const connectionTokenIdx = argv.indexOf('--connection-token'); + const connectionTokenFileIdx = argv.indexOf('--connection-token-file'); + const rawToken = connectionTokenIdx >= 0 ? argv[connectionTokenIdx + 1] : undefined; + const tokenFilePath = connectionTokenFileIdx >= 0 ? argv[connectionTokenFileIdx + 1] : undefined; + + let connectionToken: string | undefined; + if (withoutConnectionToken) { + if (rawToken !== undefined || tokenFilePath !== undefined) { + log('Error: --without-connection-token cannot be used with --connection-token or --connection-token-file'); + process.exit(1); + } + connectionToken = undefined; + } else if (tokenFilePath !== undefined) { + if (rawToken !== undefined) { + log('Error: --connection-token cannot be used with --connection-token-file'); + process.exit(1); + } + try { + connectionToken = fs.readFileSync(tokenFilePath).toString().replace(/\r?\n$/, ''); + } catch { + log(`Error: Unable to read connection token file at '${tokenFilePath}'`); + process.exit(1); + } + if (!connectionTokenRegex.test(connectionToken!)) { + log(`Error: The connection token in '${tokenFilePath}' does not adhere to the characters 0-9, a-z, A-Z, _, or -.`); + process.exit(1); + } + } else if (rawToken !== undefined) { + if (!connectionTokenRegex.test(rawToken)) { + log(`Error: The connection token '${rawToken}' does not adhere to the characters 0-9, a-z, A-Z, _, or -.`); + process.exit(1); + } + connectionToken = rawToken; + } else { + // Default: generate a random token (secure by default) + connectionToken = generateUuid(); + } + + return { port, enableMockAgent, quiet, connectionToken }; +} + +// ---- Main ------------------------------------------------------------------- + +async function main(): Promise { + const options = parseServerOptions(); + const disposables = new DisposableStore(); + + // Services + const productService: IProductService = { _serviceBrand: undefined, ...product }; + const args = parseArgs(process.argv.slice(2), OPTIONS); + const environmentService = new NativeEnvironmentService(args, productService); + + // Logging — production logging unless --quiet + let logService: ILogService; + let loggerService: LoggerService | undefined; + + if (options.quiet) { + logService = new NullLogService(); + } else { + const services = new ServiceCollection(); + services.set(IProductService, productService); + services.set(INativeEnvironmentService, environmentService); + loggerService = new LoggerService(getLogLevel(environmentService), environmentService.logsHome); + const logger = loggerService.createLogger('agenthost-server', { name: localize('agentHostServer', "Agent Host Server") }); + logService = disposables.add(new LogService(logger)); + services.set(ILogService, logService); + log('Starting standalone agent host server'); + } + + logService.info('[AgentHostServer] Starting standalone agent host server'); + + // Create state manager + const stateManager = disposables.add(new SessionStateManager(logService)); + + // Agent registry — maps provider id to agent instance + const agents = new Map(); + + // Observable agents list for root state + const registeredAgents = observableValue('agents', []); + + // File service + const fileService = disposables.add(new FileService(logService)); + disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + + // Session data service + const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); + + // Shared side-effect handler + const sideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent(session) { + const provider = AgentSession.provider(session); + return provider ? agents.get(provider) : agents.values().next().value; + }, + agents: registeredAgents, + sessionDataService, + }, logService, fileService)); + + function registerAgent(agent: IAgent): void { + agents.set(agent.id, agent); + disposables.add(sideEffects.registerProgressListener(agent)); + registeredAgents.set([...agents.values()], undefined); + logService.info(`[AgentHostServer] Registered agent: ${agent.id}`); + } + + // Register agents + if (!options.quiet) { + // Production agents (require DI) + const diServices = new ServiceCollection(); + diServices.set(IProductService, productService); + diServices.set(INativeEnvironmentService, environmentService); + diServices.set(ILogService, logService); + diServices.set(IFileService, fileService); + diServices.set(ISessionDataService, sessionDataService); + const instantiationService = new InstantiationService(diServices); + const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); + registerAgent(copilotAgent); + log('CopilotAgent registered'); + } + + if (options.enableMockAgent) { + // Dynamic import to avoid bundling test code in production + import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { + const mockAgent = disposables.add(new ScriptedMockAgent()); + registerAgent(mockAgent); + }).catch(err => { + logService.error('[AgentHostServer] Failed to load mock agent', err); + }); + } + + // WebSocket server + const wsServer = disposables.add(await WebSocketProtocolServer.create({ + port: options.port, + connectionTokenValidate: options.connectionToken + ? token => token === options.connectionToken + : undefined, + }, logService)); + + // Wire up protocol handler + disposables.add(new ProtocolServerHandler(stateManager, wsServer, sideEffects, logService)); + + // Report ready + function reportReady(addr: string): void { + const listeningPort = addr.split(':').pop(); + let wsUrl = `ws://${addr}`; + if (options.connectionToken) { + wsUrl += `?tkn=${options.connectionToken}`; + } + process.stdout.write(`READY:${listeningPort}\n`); + log(`WebSocket server listening on ${wsUrl}`); + logService.info(`[AgentHostServer] WebSocket server listening on ${wsUrl}`); + } + + const address = wsServer.address; + if (address) { + reportReady(address); + } else { + const interval = setInterval(() => { + const addr = wsServer.address; + if (addr) { + clearInterval(interval); + reportReady(addr); + } + }, 10); + } + + // Keep alive until stdin closes or signal + process.stdin.resume(); + process.stdin.on('end', shutdown); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + function shutdown(): void { + logService.info('[AgentHostServer] Shutting down...'); + disposables.dispose(); + loggerService?.dispose(); + process.exit(0); + } +} + +main(); diff --git a/src/vs/platform/agentHost/node/agentHostService.ts b/src/vs/platform/agentHost/node/agentHostService.ts new file mode 100644 index 0000000000000..7175eb47a327b --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostService.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService, ILoggerService } from '../../log/common/log.js'; +import { RemoteLoggerChannelClient } from '../../log/common/logIpc.js'; +import { IAgentHostStarter } from '../common/agent.js'; +import { AgentHostIpcChannels } from '../common/agentService.js'; + +enum Constants { + MaxRestarts = 5, +} + +/** + * Main-process service that manages the agent host utility process lifecycle + * (lazy start, crash recovery, logger forwarding). The renderer communicates + * with the utility process directly via MessagePort - this class does not + * relay any agent service calls. + */ +export class AgentHostProcessManager extends Disposable { + + private _started = false; + private _wasQuitRequested = false; + private _restartCount = 0; + + constructor( + private readonly _starter: IAgentHostStarter, + @ILogService private readonly _logService: ILogService, + @ILoggerService private readonly _loggerService: ILoggerService, + ) { + super(); + + this._register(this._starter); + + // Start lazily when the first window asks for a connection + if (this._starter.onRequestConnection) { + this._register(Event.once(this._starter.onRequestConnection)(() => this._ensureStarted())); + } + + if (this._starter.onWillShutdown) { + this._register(this._starter.onWillShutdown(() => this._wasQuitRequested = true)); + } + } + + private _ensureStarted(): void { + if (!this._started) { + this._start(); + } + } + + private _start(): void { + const connection = this._starter.start(); + + this._logService.info('AgentHostProcessManager: agent host started'); + + // Connect logger channel so agent host logs appear in the output channel + this._register(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + + // Handle unexpected exit + this._register(connection.onDidProcessExit(e => { + if (!this._wasQuitRequested && !this._store.isDisposed) { + if (this._restartCount <= Constants.MaxRestarts) { + this._logService.error(`AgentHostProcessManager: agent host terminated unexpectedly with code ${e.code}`); + this._restartCount++; + this._started = false; + connection.store.dispose(); + this._start(); + } else { + this._logService.error(`AgentHostProcessManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`); + } + } + })); + + this._register(toDisposable(() => connection.store.dispose())); + this._started = true; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts new file mode 100644 index 0000000000000..e4d16a198b244 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -0,0 +1,261 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { observableValue } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** + * The agent service implementation that runs inside the agent-host utility + * process. Dispatches to registered {@link IAgent} instances based + * on the provider identifier in the session configuration. + */ +export class AgentService extends Disposable implements IAgentService { + declare readonly _serviceBrand: undefined; + + /** Protocol: fires when state is mutated by an action. */ + private readonly _onDidAction = this._register(new Emitter()); + readonly onDidAction = this._onDidAction.event; + + /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */ + private readonly _onDidNotification = this._register(new Emitter()); + readonly onDidNotification = this._onDidNotification.event; + + /** Authoritative state manager for the sessions process protocol. */ + private readonly _stateManager: SessionStateManager; + + /** Exposes the state manager for co-hosting a WebSocket protocol server. */ + get stateManager(): SessionStateManager { return this._stateManager; } + + /** Registered providers keyed by their {@link AgentProvider} id. */ + private readonly _providers = new Map(); + /** Maps each active session URI (toString) to its owning provider. */ + private readonly _sessionToProvider = new Map(); + /** Subscriptions to provider progress events; cleared when providers change. */ + private readonly _providerSubscriptions = this._register(new DisposableStore()); + /** Default provider used when no explicit provider is specified. */ + private _defaultProvider: AgentProvider | undefined; + /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ + private readonly _agents = observableValue('agents', []); + /** Shared side-effect handler for action dispatch and session lifecycle. */ + private readonly _sideEffects: AgentSideEffects; + + constructor( + private readonly _logService: ILogService, + private readonly _fileService: IFileService, + private readonly _sessionDataService: ISessionDataService, + ) { + super(); + this._logService.info('AgentService initialized'); + this._stateManager = this._register(new SessionStateManager(_logService)); + this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); + this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); + this._sideEffects = this._register(new AgentSideEffects(this._stateManager, { + getAgent: session => this._findProviderForSession(session), + sessionDataService: this._sessionDataService, + agents: this._agents, + }, this._logService, this._fileService)); + } + + // ---- provider registration ---------------------------------------------- + + registerProvider(provider: IAgent): void { + if (this._providers.has(provider.id)) { + throw new Error(`Agent provider already registered: ${provider.id}`); + } + this._logService.info(`Registering agent provider: ${provider.id}`); + this._providers.set(provider.id, provider); + this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); + if (!this._defaultProvider) { + this._defaultProvider = provider.id; + } + + // Update root state with current agents list + this._updateAgents(); + } + + // ---- auth --------------------------------------------------------------- + + async listAgents(): Promise { + return [...this._providers.values()].map(p => p.getDescriptor()); + } + + async getResourceMetadata(): Promise { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + getResourceMetadataSync(): IResourceMetadata { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + async authenticate(params: IAuthenticateParams): Promise { + this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); + for (const provider of this._providers.values()) { + const resources = provider.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await provider.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + + // ---- session management ------------------------------------------------- + + async listSessions(): Promise { + this._logService.trace('[AgentService] listSessions called'); + const results = await Promise.all( + [...this._providers.values()].map(p => p.listSessions()) + ); + const flat = results.flat(); + this._logService.trace(`[AgentService] listSessions returned ${flat.length} sessions`); + return flat; + } + + /** + * Refreshes the model list from all providers and publishes the updated + * agents (with their models) to root state via `root/agentsChanged`. + */ + async refreshModels(): Promise { + this._logService.trace('[AgentService] refreshModels called'); + this._updateAgents(); + } + + async createSession(config?: IAgentCreateSessionConfig): Promise { + const providerId = config?.provider ?? this._defaultProvider; + const provider = providerId ? this._providers.get(providerId) : undefined; + if (!provider) { + throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); + } + this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`); + const session = await provider.createSession(config); + this._sessionToProvider.set(session.toString(), provider.id); + this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); + + // Create state in the state manager + const summary: ISessionSummary = { + resource: session.toString(), + provider: provider.id, + title: 'New Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory, + }; + this._stateManager.createSession(summary); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); + + return session; + } + + async disposeSession(session: URI): Promise { + this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); + const provider = this._findProviderForSession(session); + if (provider) { + await provider.disposeSession(session); + this._sessionToProvider.delete(session.toString()); + } + this._stateManager.removeSession(session.toString()); + this._sessionDataService.deleteSessionData(session); + } + + // ---- Protocol methods --------------------------------------------------- + + async subscribe(resource: URI): Promise { + this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); + const snapshot = this._stateManager.getSnapshot(resource.toString()); + if (!snapshot) { + throw new Error(`Cannot subscribe to unknown resource: ${resource.toString()}`); + } + return snapshot; + } + + unsubscribe(resource: URI): void { + this._logService.trace(`[AgentService] unsubscribe: ${resource.toString()}`); + // Server-side tracking of per-client subscriptions will be added + // in Phase 4 (multi-client). For now this is a no-op. + } + + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); + + const origin = { clientId, clientSeq }; + const state = this._stateManager.dispatchClientAction(action, origin); + this._logService.trace(`[AgentService] resulting state:`, state); + + this._sideEffects.handleAction(action); + } + + async browseDirectory(uri: URI): Promise { + return this._sideEffects.handleBrowseDirectory(uri.toString()); + } + + async restoreSession(session: URI): Promise { + return this._sideEffects.handleRestoreSession(session.toString()); + } + + async fetchContent(uri: URI): Promise { + return this._sideEffects.handleFetchContent(uri.toString()); + } + + async shutdown(): Promise { + this._logService.info('AgentService: shutting down all providers...'); + const promises: Promise[] = []; + for (const provider of this._providers.values()) { + promises.push(provider.shutdown()); + } + await Promise.all(promises); + this._sessionToProvider.clear(); + } + + // ---- helpers ------------------------------------------------------------ + + private _findProviderForSession(session: URI | string): IAgent | undefined { + const key = typeof session === 'string' ? session : session.toString(); + const providerId = this._sessionToProvider.get(key); + if (providerId) { + return this._providers.get(providerId); + } + const schemeProvider = AgentSession.provider(session); + if (schemeProvider) { + return this._providers.get(schemeProvider); + } + // Fallback: try the default provider (handles resumed sessions not yet tracked) + if (this._defaultProvider) { + return this._providers.get(this._defaultProvider); + } + return undefined; + } + + /** + * Sets the agents observable to trigger model re-fetch and + * `root/agentsChanged` via the autorun in {@link AgentSideEffects}. + */ + private _updateAgents(): void { + this._agents.set([...this._providers.values()], undefined); + } + + override dispose(): void { + for (const provider of this._providers.values()) { + provider.dispose(); + } + this._providers.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts new file mode 100644 index 0000000000000..5a01e7f4201c3 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -0,0 +1,481 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../base/common/observable.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgent, IAgentAttachment, IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; +import { AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, ContentEncoding, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IFetchContentResult, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../common/state/sessionProtocol.js'; +import { + ResponsePartKind, + SessionStatus, + ToolCallConfirmationReason, + ToolCallStatus, + TurnState, + type IResponsePart, + type ISessionModelInfo, + type ISessionSummary, + type IToolCallCompletedState, + type ITurn, + type URI as ProtocolURI, +} from '../common/state/sessionState.js'; +import { AgentEventMapper } from './agentEventMapper.js'; +import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** + * Options for constructing an {@link AgentSideEffects} instance. + */ +export interface IAgentSideEffectsOptions { + /** Resolve the agent responsible for a given session URI. */ + readonly getAgent: (session: ProtocolURI) => IAgent | undefined; + /** Observable set of registered agents. Triggers `root/agentsChanged` when it changes. */ + readonly agents: IObservable; + /** Session data service for cleaning up per-session data on disposal. */ + readonly sessionDataService: ISessionDataService; +} + +/** + * Shared implementation of agent side-effect handling. + * + * Routes client-dispatched actions to the correct agent backend, handles + * session create/dispose/list operations, tracks pending permission requests, + * and wires up agent progress events to the state manager. + * + * Used by both the Electron utility-process path ({@link AgentService}) and + * the standalone WebSocket server (`agentHostServerMain`). + */ +export class AgentSideEffects extends Disposable implements IProtocolSideEffectHandler { + + /** Maps tool call IDs to the agent that owns them, for routing confirmations. */ + private readonly _toolCallAgents = new Map(); + /** Per-agent event mapper instances (stateful for partId tracking). */ + private readonly _eventMappers = new Map(); + + constructor( + private readonly _stateManager: SessionStateManager, + private readonly _options: IAgentSideEffectsOptions, + private readonly _logService: ILogService, + private readonly _fileService: IFileService, + ) { + super(); + + // Whenever the agents observable changes, publish to root state. + this._register(autorun(reader => { + const agents = this._options.agents.read(reader); + this._publishAgentInfos(agents); + })); + } + + /** + * Fetches models from all agents and dispatches `root/agentsChanged`. + */ + private async _publishAgentInfos(agents: readonly IAgent[]): Promise { + const infos = await Promise.all(agents.map(async a => { + const d = a.getDescriptor(); + let models: ISessionModelInfo[]; + try { + const rawModels = await a.listModels(); + models = rawModels.map(m => ({ + id: m.id, provider: m.provider, name: m.name, + maxContextWindow: m.maxContextWindow, supportsVision: m.supportsVision, + policyState: m.policyState, + })); + } catch { + models = []; + } + return { provider: d.provider, displayName: d.displayName, description: d.description, models }; + })); + this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); + } + + // ---- Agent registration ------------------------------------------------- + + /** + * Registers a progress-event listener on the given agent so that + * `IAgentProgressEvent`s are mapped to protocol actions and dispatched + * through the state manager. Returns a disposable that removes the + * listener. + */ + registerProgressListener(agent: IAgent): IDisposable { + const disposables = new DisposableStore(); + let mapper = this._eventMappers.get(agent.id); + if (!mapper) { + mapper = new AgentEventMapper(); + this._eventMappers.set(agent.id, mapper); + } + const agentMapper = mapper; + disposables.add(agent.onDidSessionProgress(e => { + // Track tool calls so handleAction can route confirmations + if (e.type === 'tool_start') { + this._toolCallAgents.set(`${e.session.toString()}:${e.toolCallId}`, agent.id); + } + + const sessionKey = e.session.toString(); + const turnId = this._stateManager.getActiveTurnId(sessionKey); + if (turnId) { + const actions = agentMapper.mapProgressEventToActions(e, sessionKey, turnId); + if (actions) { + if (Array.isArray(actions)) { + for (const action of actions) { + this._stateManager.dispatchServerAction(action); + } + } else { + this._stateManager.dispatchServerAction(actions); + } + } + } + })); + return disposables; + } + + // ---- IProtocolSideEffectHandler ----------------------------------------- + + handleAction(action: ISessionAction): void { + switch (action.type) { + case ActionType.SessionTurnStarted: { + // Reset the event mapper's part tracking for the new turn + for (const mapper of this._eventMappers.values()) { + mapper.reset(action.session); + } + const agent = this._options.getAgent(action.session); + if (!agent) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionError, + session: action.session, + turnId: action.turnId, + error: { errorType: 'noAgent', message: 'No agent found for session' }, + }); + return; + } + const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({ + type: a.type, + path: a.path, + displayName: a.displayName, + })); + agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments).catch(err => { + this._logService.error('[AgentSideEffects] sendMessage failed', err); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionError, + session: action.session, + turnId: action.turnId, + error: { errorType: 'sendFailed', message: String(err) }, + }); + }); + break; + } + case ActionType.SessionToolCallConfirmed: { + const toolCallKey = `${action.session}:${action.toolCallId}`; + const agentId = this._toolCallAgents.get(toolCallKey); + if (agentId) { + this._toolCallAgents.delete(toolCallKey); + const agent = this._options.agents.get().find(a => a.id === agentId); + agent?.respondToPermissionRequest(action.toolCallId, action.approved); + } else { + this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`); + } + break; + } + case ActionType.SessionTurnCancelled: { + const agent = this._options.getAgent(action.session); + agent?.abortSession(URI.parse(action.session)).catch(err => { + this._logService.error('[AgentSideEffects] abortSession failed', err); + }); + break; + } + case ActionType.SessionModelChanged: { + const agent = this._options.getAgent(action.session); + agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => { + this._logService.error('[AgentSideEffects] changeModel failed', err); + }); + break; + } + } + } + + async handleCreateSession(command: ICreateSessionParams): Promise { + const provider = command.provider; + if (!provider) { + throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, 'No provider specified for session creation'); + } + const agent = this._options.agents.get().find(a => a.id === provider); + if (!agent) { + throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, `No agent registered for provider: ${provider}`); + } + // Use the client-provided session URI per the protocol spec + const session = command.session; + await agent.createSession({ + provider, + model: command.model, + workingDirectory: command.workingDirectory, + session: URI.parse(session), + }); + const summary: ISessionSummary = { + resource: session, + provider, + title: 'Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: command.workingDirectory, + }; + this._stateManager.createSession(summary); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); + } + + handleDisposeSession(session: ProtocolURI): void { + const agent = this._options.getAgent(session); + agent?.disposeSession(URI.parse(session)).catch(() => { }); + this._stateManager.removeSession(session); + this._options.sessionDataService.deleteSessionData(URI.parse(session)); + } + + async handleListSessions(): Promise { + const allSessions: ISessionSummary[] = []; + for (const agent of this._options.agents.get()) { + const sessions = await agent.listSessions(); + const provider = agent.id; + for (const s of sessions) { + allSessions.push({ + resource: s.session.toString(), + provider, + title: s.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: s.startTime, + modifiedAt: s.modifiedTime, + }); + } + } + return allSessions; + } + + /** + * Restores a session from a previous server lifetime into the state + * manager. Fetches the session's message history from the agent backend, + * reconstructs `ITurn[]`, and creates the session in the state manager. + * + * @throws {ProtocolError} if the session URI doesn't match any agent or + * the agent cannot retrieve the session messages. + */ + async handleRestoreSession(session: ProtocolURI): Promise { + // Already in state manager - nothing to do. + if (this._stateManager.getSessionState(session)) { + return; + } + + const agent = this._options.getAgent(session); + if (!agent) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${session}`); + } + + // Verify the session actually exists on the backend to avoid + // creating phantom sessions for made-up URIs. + let allSessions; + try { + allSessions = await agent.listSessions(); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${session}: ${message}`); + } + const meta = allSessions.find(s => s.session.toString() === session); + if (!meta) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${session}`); + } + + const sessionUri = URI.parse(session); + let messages; + try { + messages = await agent.getSessionMessages(sessionUri); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${session}: ${message}`); + } + const turns = this._buildTurnsFromMessages(messages); + + const summary: ISessionSummary = { + resource: session, + provider: agent.id, + title: meta.summary ?? 'Session', + status: SessionStatus.Idle, + createdAt: meta.startTime, + modifiedAt: meta.modifiedTime, + workingDirectory: meta.workingDirectory, + }; + + this._stateManager.restoreSession(summary, turns); + this._logService.info(`[AgentSideEffects] Restored session ${session} with ${turns.length} turns`); + } + + /** + * Reconstructs completed `ITurn[]` from a sequence of agent session + * messages (user messages, assistant messages, tool starts, tool + * completions). Each user-message starts a new turn; the assistant + * message closes it. + */ + private _buildTurnsFromMessages( + messages: readonly (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[], + ): ITurn[] { + const turns: ITurn[] = []; + let currentTurn: { + id: string; + userMessage: { text: string }; + responseParts: IResponsePart[]; + pendingTools: Map; + } | undefined; + + let turnCounter = 0; + + const finalizeTurn = (turn: NonNullable, state: TurnState): void => { + turns.push({ + id: turn.id, + userMessage: turn.userMessage, + responseParts: turn.responseParts, + usage: undefined, + state, + }); + }; + + const startTurn = (text: string): NonNullable => ({ + id: `restored-${turnCounter++}`, + userMessage: { text }, + responseParts: [], + pendingTools: new Map(), + }); + + for (const msg of messages) { + if (msg.type === 'message' && msg.role === 'user') { + // Flush any in-progress turn (e.g. interrupted/cancelled + // turn that never got a closing assistant message). + if (currentTurn) { + finalizeTurn(currentTurn, TurnState.Cancelled); + } + currentTurn = startTurn(msg.content); + } else if (msg.type === 'message' && msg.role === 'assistant') { + if (!currentTurn) { + currentTurn = startTurn(''); + } + + if (msg.content) { + currentTurn.responseParts.push({ + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: msg.content, + }); + } + + if (!msg.toolRequests || msg.toolRequests.length === 0) { + finalizeTurn(currentTurn, TurnState.Complete); + currentTurn = undefined; + } + } else if (msg.type === 'tool_start') { + currentTurn?.pendingTools.set(msg.toolCallId, msg); + } else if (msg.type === 'tool_complete') { + if (currentTurn) { + const start = currentTurn.pendingTools.get(msg.toolCallId); + currentTurn.pendingTools.delete(msg.toolCallId); + + const tc: IToolCallCompletedState = { + status: ToolCallStatus.Completed, + toolCallId: msg.toolCallId, + toolName: start?.toolName ?? 'unknown', + displayName: start?.displayName ?? 'Unknown Tool', + invocationMessage: start?.invocationMessage ?? '', + toolInput: start?.toolInput, + success: msg.result.success, + pastTenseMessage: msg.result.pastTenseMessage, + content: msg.result.content, + error: msg.result.error, + confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: start ? { + toolKind: start.toolKind, + language: start.language, + } : undefined, + }; + currentTurn.responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: tc, + }); + } + } + } + + if (currentTurn) { + finalizeTurn(currentTurn, TurnState.Cancelled); + } + + return turns; + } + + handleGetResourceMetadata(): IResourceMetadata { + const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); + return { resources }; + } + + async handleAuthenticate(params: IAuthenticateParams): Promise { + for (const agent of this._options.agents.get()) { + const resources = agent.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await agent.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } + } + return { authenticated: false }; + } + + async handleBrowseDirectory(uri: ProtocolURI): Promise { + let stat; + try { + stat = await this._fileService.resolve(URI.parse(uri)); + } catch { + throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`); + } + + if (!stat.isDirectory) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`); + } + + const entries: IDirectoryEntry[] = (stat.children ?? []).map(child => ({ + name: child.name, + type: child.isDirectory ? 'directory' : 'file', + })); + return { entries }; + } + + getDefaultDirectory(): ProtocolURI { + return URI.file(os.homedir()).toString(); + } + + async handleFetchContent(uri: ProtocolURI): Promise { + try { + const content = await this._fileService.readFile(URI.parse(uri)); + return { + data: content.value.toString(), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } catch (_e) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri}`); + } + } + + override dispose(): void { + this._toolCallAgents.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts new file mode 100644 index 0000000000000..a4bfbf4a65f88 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -0,0 +1,844 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +import { delimiter, dirname } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; +import { localize } from '../../../../nls.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ToolResultContentType, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js'; +import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; +import { FileEditTracker } from './fileEditTracker.js'; + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +/** + * Agent provider backed by the Copilot SDK {@link CopilotClient}. + */ +export class CopilotAgent extends Disposable implements IAgent { + readonly id = 'copilot' as const; + + private readonly _onDidSessionProgress = this._register(new Emitter()); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private _client: CopilotClient | undefined; + private _clientStarting: Promise | undefined; + private _githubToken: string | undefined; + private readonly _sessions = this._register(new DisposableMap()); + /** Tracks active tool invocations so we can produce past-tense messages on completion. Keyed by `sessionId:toolCallId`. */ + private readonly _activeToolCalls = new Map | undefined }>(); + /** Pending permission requests awaiting a renderer-side decision. Keyed by requestId. */ + private readonly _pendingPermissions = new Map }>(); + /** Working directory per session, used when resuming. */ + private readonly _sessionWorkingDirs = new Map(); + /** File edit trackers per session, keyed by raw session ID. */ + private readonly _editTrackers = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); + } + + // ---- auth --------------------------------------------------------------- + + getDescriptor(): IAgentDescriptor { + return { + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'Copilot SDK agent running in a dedicated process', + requiresAuth: true, + }; + } + + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + }]; + } + + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } + const tokenChanged = this._githubToken !== token; + this._githubToken = token; + this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); + if (tokenChanged && this._client && this._sessions.size === 0) { + this._logService.info('[Copilot] Restarting CopilotClient with new token'); + const client = this._client; + this._client = undefined; + this._clientStarting = undefined; + await client.stop(); + } + return true; + } + + // ---- client lifecycle --------------------------------------------------- + + private async _ensureClient(): Promise { + if (this._client) { + return this._client; + } + if (this._clientStarting) { + return this._clientStarting; + } + this._clientStarting = (async () => { + this._logService.info(`[Copilot] Starting CopilotClient... ${this._githubToken ? '(with token)' : '(no token)'}`); + + // Build a clean env for the CLI subprocess, stripping Electron/VS Code vars + // that can interfere with the Node.js process the SDK spawns. + const env: Record = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' }); + delete env['NODE_OPTIONS']; + delete env['VSCODE_INSPECTOR_OPTIONS']; + delete env['VSCODE_ESM_ENTRYPOINT']; + delete env['VSCODE_HANDLES_UNCAUGHT_ERRORS']; + for (const key of Object.keys(env)) { + if (key === 'ELECTRON_RUN_AS_NODE') { + continue; + } + if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { + delete env[key]; + } + } + env['COPILOT_CLI_RUN_AS_NODE'] = '1'; + env['USE_BUILTIN_RIPGREP'] = '0'; + + // Resolve the CLI entry point from node_modules. We can't use require.resolve() + // because @github/copilot's exports map blocks direct subpath access. + // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. + const cliPath = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@github', 'copilot', 'index.js').fsPath; + + // Add VS Code's built-in ripgrep to PATH so the CLI subprocess can find it. + // If @vscode/ripgrep is in an .asar file, the binary is unpacked. + const rgDiskPath = rgPath.replace(/\bnode_modules\.asar\b/, 'node_modules.asar.unpacked'); + const rgDir = dirname(rgDiskPath); + // On Windows the env key is typically "Path" (not "PATH"). Since we copied + // process.env into a plain (case-sensitive) object, we must find the actual key. + const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + const currentPath = env[pathKey]; + env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; + this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); + + const client = new CopilotClient({ + githubToken: this._githubToken, + useLoggedInUser: !this._githubToken, + useStdio: true, + autoStart: true, + env, + cliPath, + }); + await client.start(); + this._logService.info('[Copilot] CopilotClient started successfully'); + this._client = client; + this._clientStarting = undefined; + return client; + })(); + return this._clientStarting; + } + + // ---- session management ------------------------------------------------- + + async listSessions(): Promise { + this._logService.info('[Copilot] Listing sessions...'); + const client = await this._ensureClient(); + const sessions = await client.listSessions(); + const result: IAgentSessionMetadata[] = sessions.map(s => ({ + session: AgentSession.uri(this.id, s.sessionId), + startTime: s.startTime.getTime(), + modifiedTime: s.modifiedTime.getTime(), + summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? s.context.cwd : undefined, + })); + this._logService.info(`[Copilot] Found ${result.length} sessions`); + return result; + } + + async listModels(): Promise { + this._logService.info('[Copilot] Listing models...'); + const client = await this._ensureClient(); + const models = await client.listModels(); + const result = models.map(m => ({ + provider: this.id, + id: m.id, + name: m.name, + maxContextWindow: m.capabilities.limits.max_context_window_tokens, + supportsVision: m.capabilities.supports.vision, + supportsReasoningEffort: m.capabilities.supports.reasoningEffort, + supportedReasoningEfforts: m.supportedReasoningEfforts, + defaultReasoningEffort: m.defaultReasoningEffort, + policyState: m.policy?.state as PolicyState | undefined, + billingMultiplier: m.billing?.multiplier, + })); + this._logService.info(`[Copilot] Found ${result.length} models`); + return result; + } + + async createSession(config?: IAgentCreateSessionConfig): Promise { + this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`); + const client = await this._ensureClient(); + const raw = await client.createSession({ + model: config?.model, + sessionId: config?.session ? AgentSession.id(config.session) : undefined, + streaming: true, + workingDirectory: config?.workingDirectory, + onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), + hooks: this._createSessionHooks(), + }); + + const wrapper = this._trackSession(raw); + const session = AgentSession.uri(this.id, wrapper.sessionId); + if (config?.workingDirectory) { + this._sessionWorkingDirs.set(wrapper.sessionId, config.workingDirectory); + } + this._logService.info(`[Copilot] Session created: ${session.toString()}`); + return session; + } + + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { + const sessionId = AgentSession.id(session); + this._logService.info(`[Copilot:${sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); + this._logService.info(`[Copilot:${sessionId}] Found session wrapper, calling session.send()...`); + + const sdkAttachments = attachments?.map(a => { + if (a.type === 'selection') { + return { type: 'selection' as const, filePath: a.path, displayName: a.displayName ?? a.path, text: a.text, selection: a.selection }; + } + return { type: a.type, path: a.path, displayName: a.displayName }; + }); + if (sdkAttachments?.length) { + this._logService.trace(`[Copilot:${sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); + } + + await entry.session.send({ prompt, attachments: sdkAttachments }); + this._logService.info(`[Copilot:${sessionId}] session.send() returned`); + } + + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + if (!entry) { + return []; + } + + const events = await entry.session.getMessages(); + return this._mapSessionEvents(session, events); + } + + async disposeSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + this._sessions.deleteAndDispose(sessionId); + this._clearToolCallsForSession(sessionId); + this._sessionWorkingDirs.delete(sessionId); + this._denyPendingPermissionsForSession(sessionId); + } + + async abortSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (entry) { + this._logService.info(`[Copilot:${sessionId}] Aborting session...`); + this._denyPendingPermissionsForSession(sessionId); + await entry.session.abort(); + } + } + + async changeModel(session: URI, model: string): Promise { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (entry) { + this._logService.info(`[Copilot:${sessionId}] Changing model to: ${model}`); + await entry.session.setModel(model); + } + } + + async shutdown(): Promise { + this._logService.info('[Copilot] Shutting down...'); + this._sessions.clearAndDisposeAll(); + this._activeToolCalls.clear(); + this._sessionWorkingDirs.clear(); + this._denyPendingPermissions(); + await this._client?.stop(); + this._client = undefined; + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + const entry = this._pendingPermissions.get(requestId); + if (entry) { + this._pendingPermissions.delete(requestId); + entry.deferred.complete(approved); + } + } + + /** + * Returns true if this provider owns the given session ID. + */ + hasSession(session: URI): boolean { + return this._sessions.has(AgentSession.id(session)); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Handles a permission request from the SDK by firing a `tool_ready` event + * (which transitions the tool to PendingConfirmation) and waiting for the + * side-effects layer to respond via respondToPermissionRequest. + */ + private async _handlePermissionRequest( + request: { kind: string; toolCallId?: string;[key: string]: unknown }, + invocation: { sessionId: string }, + ): Promise<{ kind: 'approved' | 'denied-interactively-by-user' }> { + const session = AgentSession.uri(this.id, invocation.sessionId); + + this._logService.info(`[Copilot:${invocation.sessionId}] Permission request: kind=${request.kind}`); + + // Auto-approve reads inside the working directory + if (request.kind === 'read') { + const requestPath = typeof request.path === 'string' ? request.path : undefined; + const workingDir = this._sessionWorkingDirs.get(invocation.sessionId); + if (requestPath && workingDir && requestPath.startsWith(workingDir)) { + this._logService.trace(`[Copilot:${invocation.sessionId}] Auto-approving read inside working directory: ${requestPath}`); + return { kind: 'approved' }; + } + } + + const toolCallId = request.toolCallId; + if (!toolCallId) { + // TODO: handle permission requests without a toolCallId by creating a synthetic tool call + this._logService.warn(`[Copilot:${invocation.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); + return { kind: 'denied-interactively-by-user' }; + } + + this._logService.info(`[Copilot:${invocation.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); + + const deferred = new DeferredPromise(); + this._pendingPermissions.set(toolCallId, { sessionId: invocation.sessionId, deferred }); + + // Derive display information from the permission request kind + const { confirmationTitle, invocationMessage, toolInput } = this._getPermissionDisplay(request); + + // Fire a tool_ready event to transition the tool to PendingConfirmation + this._onDidSessionProgress.fire({ + session, + type: 'tool_ready', + toolCallId, + invocationMessage, + toolInput, + confirmationTitle, + }); + + const approved = await deferred.p; + this._logService.info(`[Copilot:${invocation.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`); + return { kind: approved ? 'approved' : 'denied-interactively-by-user' }; + } + + /** + * Derives display fields from a permission request for the tool confirmation UI. + */ + private _getPermissionDisplay(request: { kind: string;[key: string]: unknown }): { + confirmationTitle: string; + invocationMessage: string; + toolInput?: string; + } { + const path = typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined); + const fullCommandText = typeof request.fullCommandText === 'string' ? request.fullCommandText : undefined; + const intention = typeof request.intention === 'string' ? request.intention : undefined; + const serverName = typeof request.serverName === 'string' ? request.serverName : undefined; + const toolName = typeof request.toolName === 'string' ? request.toolName : undefined; + + switch (request.kind) { + case 'shell': + return { + confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), + invocationMessage: intention ?? localize('copilot.permission.shell.message', "Run command"), + toolInput: fullCommandText, + }; + case 'write': + return { + confirmationTitle: localize('copilot.permission.write.title', "Write file"), + invocationMessage: path ? localize('copilot.permission.write.message', "Edit {0}", path) : localize('copilot.permission.write.messageGeneric', "Edit file"), + toolInput: tryStringify(path ? { path } : request) ?? undefined, + }; + case 'mcp': { + const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); + return { + confirmationTitle: serverName ? `${serverName}: ${title}` : title, + invocationMessage: serverName ? `${serverName}: ${title}` : title, + toolInput: tryStringify({ serverName, toolName }) ?? undefined, + }; + } + case 'read': + return { + confirmationTitle: localize('copilot.permission.read.title', "Read file"), + invocationMessage: intention ?? localize('copilot.permission.read.message', "Read file"), + toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, + }; + default: + return { + confirmationTitle: localize('copilot.permission.default.title', "Permission request"), + invocationMessage: localize('copilot.permission.default.message', "Permission request"), + toolInput: tryStringify(request) ?? undefined, + }; + } + } + + private _clearToolCallsForSession(sessionId: string): void { + const prefix = `${sessionId}:`; + for (const key of this._activeToolCalls.keys()) { + if (key.startsWith(prefix)) { + this._activeToolCalls.delete(key); + } + } + } + + private _getOrCreateEditTracker(rawSessionId: string): FileEditTracker { + let tracker = this._editTrackers.get(rawSessionId); + if (!tracker) { + tracker = new FileEditTracker(rawSessionId, this._sessionDataService, this._fileService, this._logService); + this._editTrackers.set(rawSessionId, tracker); + } + return tracker; + } + + /** + * Creates SDK session hooks for pre/post tool use. The `onPreToolUse` + * hook snapshots files before edit tools run. The `onPostToolUse` hook + * snapshots the after-content so that it's ready synchronously when + * `onToolComplete` fires. + */ + private _createSessionHooks() { + return { + onPreToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => { + if (isEditTool(input.toolName)) { + const filePath = getEditFilePath(input.toolArgs); + if (filePath) { + const tracker = this._getOrCreateEditTracker(invocation.sessionId); + await tracker.trackEditStart(filePath); + } + } + }, + onPostToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => { + if (isEditTool(input.toolName)) { + const filePath = getEditFilePath(input.toolArgs); + if (filePath) { + const tracker = this._editTrackers.get(invocation.sessionId); + await tracker?.completeEdit(filePath); + } + } + }, + }; + } + + private _trackSession(raw: CopilotSession, sessionIdOverride?: string): CopilotSessionWrapper { + const wrapper = new CopilotSessionWrapper(raw); + const rawId = sessionIdOverride ?? wrapper.sessionId; + const session = AgentSession.uri(this.id, rawId); + + wrapper.onMessageDelta(e => { + this._logService.trace(`[Copilot:${rawId}] delta: ${e.data.deltaContent}`); + this._onDidSessionProgress.fire({ + session, + type: 'delta', + messageId: e.data.messageId, + content: e.data.deltaContent, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onMessage(e => { + this._logService.info(`[Copilot:${rawId}] Full message received: ${e.data.content.length} chars`); + this._onDidSessionProgress.fire({ + session, + type: 'message', + role: 'assistant', + messageId: e.data.messageId, + content: e.data.content, + toolRequests: e.data.toolRequests?.map(tr => ({ + toolCallId: tr.toolCallId, + name: tr.name, + arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, + type: tr.type, + })), + reasoningOpaque: e.data.reasoningOpaque, + reasoningText: e.data.reasoningText, + encryptedContent: e.data.encryptedContent, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onToolStart(e => { + if (isHiddenTool(e.data.toolName)) { + this._logService.trace(`[Copilot:${rawId}] Tool started (hidden): ${e.data.toolName}`); + return; + } + this._logService.info(`[Copilot:${rawId}] Tool started: ${e.data.toolName}`); + const toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined; + let parameters: Record | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } + } + const displayName = getToolDisplayName(e.data.toolName); + const trackingKey = `${rawId}:${e.data.toolCallId}`; + this._activeToolCalls.set(trackingKey, { toolName: e.data.toolName, displayName, parameters }); + const toolKind = getToolKind(e.data.toolName); + + this._onDidSessionProgress.fire({ + session, + type: 'tool_start', + toolCallId: e.data.toolCallId, + toolName: e.data.toolName, + displayName, + invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), + toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined, + toolArguments: toolArgs, + mcpServerName: e.data.mcpServerName, + mcpToolName: e.data.mcpToolName, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onToolComplete(e => { + const trackingKey = `${rawId}:${e.data.toolCallId}`; + const tracked = this._activeToolCalls.get(trackingKey); + if (!tracked) { + return; + } + this._logService.info(`[Copilot:${rawId}] Tool completed: ${e.data.toolCallId}`); + this._activeToolCalls.delete(trackingKey); + const displayName = tracked.displayName; + const toolOutput = e.data.error?.message ?? e.data.result?.content; + + const content: IToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + + // File edit data was already prepared by the onPostToolUse hook + const tracker = this._editTrackers.get(rawId); + const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined; + if (tracker && filePath) { + const fileEdit = tracker.takeCompletedEdit(filePath); + if (fileEdit) { + content.push(fileEdit); + } + } + + this._onDidSessionProgress.fire({ + session, + type: 'tool_complete', + toolCallId: e.data.toolCallId, + result: { + success: e.data.success, + pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success), + content: content.length > 0 ? content : undefined, + error: e.data.error, + }, + isUserRequested: e.data.isUserRequested, + toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined, + parentToolCallId: e.data.parentToolCallId, + }); + }); + + wrapper.onIdle(() => { + this._logService.info(`[Copilot:${rawId}] Session idle`); + this._onDidSessionProgress.fire({ session, type: 'idle' }); + }); + + wrapper.onSessionError(e => { + this._logService.error(`[Copilot:${rawId}] Session error: ${e.data.errorType} - ${e.data.message}`); + this._onDidSessionProgress.fire({ + session, + type: 'error', + errorType: e.data.errorType, + message: e.data.message, + stack: e.data.stack, + }); + }); + + wrapper.onUsage(e => { + this._logService.trace(`[Copilot:${rawId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`); + this._onDidSessionProgress.fire({ + session, + type: 'usage', + inputTokens: e.data.inputTokens, + outputTokens: e.data.outputTokens, + model: e.data.model, + cacheReadTokens: e.data.cacheReadTokens, + }); + }); + + wrapper.onReasoningDelta(e => { + this._logService.trace(`[Copilot:${rawId}] Reasoning delta: ${e.data.deltaContent.length} chars`); + this._onDidSessionProgress.fire({ + session, + type: 'reasoning', + content: e.data.deltaContent, + }); + }); + + this._subscribeForLogging(wrapper, rawId); + + this._sessions.set(rawId, wrapper); + return wrapper; + } + + private _subscribeForLogging(wrapper: CopilotSessionWrapper, sessionId: string): void { + wrapper.onSessionStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); + }); + + wrapper.onSessionResume(e => { + this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); + }); + + wrapper.onSessionInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`); + }); + + wrapper.onSessionModelChange(e => { + this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); + }); + + wrapper.onSessionHandoff(e => { + this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); + }); + + wrapper.onSessionTruncation(e => { + this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); + }); + + wrapper.onSessionSnapshotRewind(e => { + this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); + }); + + wrapper.onSessionShutdown(e => { + this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`); + }); + + wrapper.onSessionUsageInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); + }); + + wrapper.onSessionCompactionStart(() => { + this._logService.trace(`[Copilot:${sessionId}] Compaction started`); + }); + + wrapper.onSessionCompactionComplete(e => { + this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); + }); + + wrapper.onUserMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); + }); + + wrapper.onPendingMessagesModified(() => { + this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); + }); + + wrapper.onTurnStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); + }); + + wrapper.onIntent(e => { + this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); + }); + + wrapper.onReasoning(e => { + this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); + }); + + wrapper.onTurnEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); + }); + + wrapper.onAbort(e => { + this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); + }); + + wrapper.onToolUserRequested(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); + }); + + wrapper.onToolPartialResult(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); + }); + + wrapper.onToolProgress(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); + }); + + wrapper.onSkillInvoked(e => { + this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); + }); + + wrapper.onSubagentStarted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); + }); + + wrapper.onSubagentCompleted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); + }); + + wrapper.onSubagentFailed(e => { + this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); + }); + + wrapper.onSubagentSelected(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); + }); + + wrapper.onHookStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); + }); + + wrapper.onHookEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); + }); + + wrapper.onSystemMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); + }); + } + + private async _resumeSession(sessionId: string): Promise { + this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); + const client = await this._ensureClient(); + const raw = await client.resumeSession(sessionId, { + onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), + workingDirectory: this._sessionWorkingDirs.get(sessionId), + hooks: this._createSessionHooks(), + }); + return this._trackSession(raw, sessionId); + } + + private _mapSessionEvents(session: URI, events: readonly SessionEvent[]): (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] { + const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + const toolInfoByCallId = new Map | undefined }>(); + + for (const e of events) { + if (e.type === 'assistant.message' || e.type === 'user.message') { + const d = (e as SessionEventPayload<'assistant.message'>).data; + result.push({ + session, + type: 'message', + role: e.type === 'user.message' ? 'user' : 'assistant', + messageId: d?.messageId ?? '', + content: d?.content ?? '', + toolRequests: d?.toolRequests?.map((tr: { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }) => ({ + toolCallId: tr.toolCallId, + name: tr.name, + arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined, + type: tr.type, + })), + reasoningOpaque: d?.reasoningOpaque, + reasoningText: d?.reasoningText, + encryptedContent: d?.encryptedContent, + parentToolCallId: d?.parentToolCallId, + }); + } else if (e.type === 'tool.execution_start') { + const d = (e as SessionEventPayload<'tool.execution_start'>).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + let parameters: Record | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } + } + toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters }); + const displayName = getToolDisplayName(d.toolName); + const toolKind = getToolKind(d.toolName); + result.push({ + session, + type: 'tool_start', + toolCallId: d.toolCallId, + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, parameters), + toolInput: getToolInputString(d.toolName, parameters, toolArgs), + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(d.toolName) : undefined, + toolArguments: toolArgs, + mcpServerName: d.mcpServerName, + mcpToolName: d.mcpToolName, + parentToolCallId: d.parentToolCallId, + }); + } else if (e.type === 'tool.execution_complete') { + const d = (e as SessionEventPayload<'tool.execution_complete'>).data; + const info = toolInfoByCallId.get(d.toolCallId); + if (!info) { + continue; + } + toolInfoByCallId.delete(d.toolCallId); + const displayName = getToolDisplayName(info.toolName); + const toolOutput = d.error?.message ?? d.result?.content; + const content: IToolResultContent[] = []; + if (toolOutput !== undefined) { + content.push({ type: ToolResultContentType.Text, text: toolOutput }); + } + result.push({ + session, + type: 'tool_complete', + toolCallId: d.toolCallId, + result: { + success: d.success, + pastTenseMessage: getPastTenseMessage(info.toolName, displayName, info.parameters, d.success), + content: content.length > 0 ? content : undefined, + error: d.error, + }, + isUserRequested: d.isUserRequested, + toolTelemetry: d.toolTelemetry !== undefined ? tryStringify(d.toolTelemetry) : undefined, + }); + } + } + return result; + } + + override dispose(): void { + this._denyPendingPermissions(); + this._client?.stop().catch(() => { /* best-effort */ }); + super.dispose(); + } + + private _denyPendingPermissions(): void { + for (const [, entry] of this._pendingPermissions) { + entry.deferred.complete(false); + } + this._pendingPermissions.clear(); + } + + private _denyPendingPermissionsForSession(sessionId: string): void { + for (const [requestId, entry] of this._pendingPermissions) { + if (entry.sessionId === sessionId) { + entry.deferred.complete(false); + this._pendingPermissions.delete(requestId); + } + } + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts new file mode 100644 index 0000000000000..36ad526d4167a --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotSession, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; + +/** + * Thin wrapper around {@link CopilotSession} that exposes each SDK event as a + * proper VS Code `Event`. All subscriptions and the underlying SDK session + * are cleaned up on dispose. + */ +export class CopilotSessionWrapper extends Disposable { + + constructor(readonly session: CopilotSession) { + super(); + this._register(toDisposable(() => { + session.destroy().catch(() => { /* best-effort */ }); + })); + } + + get sessionId(): string { return this.session.sessionId; } + + private _onMessageDelta: Event> | undefined; + get onMessageDelta(): Event> { + return this._onMessageDelta ??= this._sdkEvent('assistant.message_delta'); + } + + private _onMessage: Event> | undefined; + get onMessage(): Event> { + return this._onMessage ??= this._sdkEvent('assistant.message'); + } + + private _onToolStart: Event> | undefined; + get onToolStart(): Event> { + return this._onToolStart ??= this._sdkEvent('tool.execution_start'); + } + + private _onToolComplete: Event> | undefined; + get onToolComplete(): Event> { + return this._onToolComplete ??= this._sdkEvent('tool.execution_complete'); + } + + private _onIdle: Event> | undefined; + get onIdle(): Event> { + return this._onIdle ??= this._sdkEvent('session.idle'); + } + + private _onSessionStart: Event> | undefined; + get onSessionStart(): Event> { + return this._onSessionStart ??= this._sdkEvent('session.start'); + } + + private _onSessionResume: Event> | undefined; + get onSessionResume(): Event> { + return this._onSessionResume ??= this._sdkEvent('session.resume'); + } + + private _onSessionError: Event> | undefined; + get onSessionError(): Event> { + return this._onSessionError ??= this._sdkEvent('session.error'); + } + + private _onSessionInfo: Event> | undefined; + get onSessionInfo(): Event> { + return this._onSessionInfo ??= this._sdkEvent('session.info'); + } + + private _onSessionModelChange: Event> | undefined; + get onSessionModelChange(): Event> { + return this._onSessionModelChange ??= this._sdkEvent('session.model_change'); + } + + private _onSessionHandoff: Event> | undefined; + get onSessionHandoff(): Event> { + return this._onSessionHandoff ??= this._sdkEvent('session.handoff'); + } + + private _onSessionTruncation: Event> | undefined; + get onSessionTruncation(): Event> { + return this._onSessionTruncation ??= this._sdkEvent('session.truncation'); + } + + private _onSessionSnapshotRewind: Event> | undefined; + get onSessionSnapshotRewind(): Event> { + return this._onSessionSnapshotRewind ??= this._sdkEvent('session.snapshot_rewind'); + } + + private _onSessionShutdown: Event> | undefined; + get onSessionShutdown(): Event> { + return this._onSessionShutdown ??= this._sdkEvent('session.shutdown'); + } + + private _onSessionUsageInfo: Event> | undefined; + get onSessionUsageInfo(): Event> { + return this._onSessionUsageInfo ??= this._sdkEvent('session.usage_info'); + } + + private _onSessionCompactionStart: Event> | undefined; + get onSessionCompactionStart(): Event> { + return this._onSessionCompactionStart ??= this._sdkEvent('session.compaction_start'); + } + + private _onSessionCompactionComplete: Event> | undefined; + get onSessionCompactionComplete(): Event> { + return this._onSessionCompactionComplete ??= this._sdkEvent('session.compaction_complete'); + } + + private _onUserMessage: Event> | undefined; + get onUserMessage(): Event> { + return this._onUserMessage ??= this._sdkEvent('user.message'); + } + + private _onPendingMessagesModified: Event> | undefined; + get onPendingMessagesModified(): Event> { + return this._onPendingMessagesModified ??= this._sdkEvent('pending_messages.modified'); + } + + private _onTurnStart: Event> | undefined; + get onTurnStart(): Event> { + return this._onTurnStart ??= this._sdkEvent('assistant.turn_start'); + } + + private _onIntent: Event> | undefined; + get onIntent(): Event> { + return this._onIntent ??= this._sdkEvent('assistant.intent'); + } + + private _onReasoning: Event> | undefined; + get onReasoning(): Event> { + return this._onReasoning ??= this._sdkEvent('assistant.reasoning'); + } + + private _onReasoningDelta: Event> | undefined; + get onReasoningDelta(): Event> { + return this._onReasoningDelta ??= this._sdkEvent('assistant.reasoning_delta'); + } + + private _onTurnEnd: Event> | undefined; + get onTurnEnd(): Event> { + return this._onTurnEnd ??= this._sdkEvent('assistant.turn_end'); + } + + private _onUsage: Event> | undefined; + get onUsage(): Event> { + return this._onUsage ??= this._sdkEvent('assistant.usage'); + } + + private _onAbort: Event> | undefined; + get onAbort(): Event> { + return this._onAbort ??= this._sdkEvent('abort'); + } + + private _onToolUserRequested: Event> | undefined; + get onToolUserRequested(): Event> { + return this._onToolUserRequested ??= this._sdkEvent('tool.user_requested'); + } + + private _onToolPartialResult: Event> | undefined; + get onToolPartialResult(): Event> { + return this._onToolPartialResult ??= this._sdkEvent('tool.execution_partial_result'); + } + + private _onToolProgress: Event> | undefined; + get onToolProgress(): Event> { + return this._onToolProgress ??= this._sdkEvent('tool.execution_progress'); + } + + private _onSkillInvoked: Event> | undefined; + get onSkillInvoked(): Event> { + return this._onSkillInvoked ??= this._sdkEvent('skill.invoked'); + } + + private _onSubagentStarted: Event> | undefined; + get onSubagentStarted(): Event> { + return this._onSubagentStarted ??= this._sdkEvent('subagent.started'); + } + + private _onSubagentCompleted: Event> | undefined; + get onSubagentCompleted(): Event> { + return this._onSubagentCompleted ??= this._sdkEvent('subagent.completed'); + } + + private _onSubagentFailed: Event> | undefined; + get onSubagentFailed(): Event> { + return this._onSubagentFailed ??= this._sdkEvent('subagent.failed'); + } + + private _onSubagentSelected: Event> | undefined; + get onSubagentSelected(): Event> { + return this._onSubagentSelected ??= this._sdkEvent('subagent.selected'); + } + + private _onHookStart: Event> | undefined; + get onHookStart(): Event> { + return this._onHookStart ??= this._sdkEvent('hook.start'); + } + + private _onHookEnd: Event> | undefined; + get onHookEnd(): Event> { + return this._onHookEnd ??= this._sdkEvent('hook.end'); + } + + private _onSystemMessage: Event> | undefined; + get onSystemMessage(): Event> { + return this._onSystemMessage ??= this._sdkEvent('system.message'); + } + + private _sdkEvent(eventType: K): Event> { + const emitter = this._register(new Emitter>()); + const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); + this._register(toDisposable(unsubscribe)); + return emitter.event; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts new file mode 100644 index 0000000000000..397c4b0fb71e2 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; + +// ============================================================================= +// Copilot CLI built-in tool interfaces +// +// The Copilot CLI (via @github/copilot) exposes these built-in tools. Tool names +// and parameter shapes are not typed in the SDK -- they come from the CLI server +// as plain strings. These interfaces are derived from observing the CLI's actual +// tool events and the ShellConfig class in @github/copilot. +// +// Shell tool names follow a pattern per ShellConfig: +// shellToolName, readShellToolName, writeShellToolName, +// stopShellToolName, listShellsToolName +// For bash: bash, read_bash, write_bash, bash_shutdown, list_bash +// For powershell: powershell, read_powershell, write_powershell, list_powershell +// ============================================================================= + +/** + * Known Copilot CLI tool names. These are the `toolName` values that appear + * in `tool.execution_start` events from the SDK. + */ +const enum CopilotToolName { + Bash = 'bash', + ReadBash = 'read_bash', + WriteBash = 'write_bash', + BashShutdown = 'bash_shutdown', + ListBash = 'list_bash', + + PowerShell = 'powershell', + ReadPowerShell = 'read_powershell', + WritePowerShell = 'write_powershell', + ListPowerShell = 'list_powershell', + + View = 'view', + Edit = 'edit', + Write = 'write', + Grep = 'grep', + Glob = 'glob', + Patch = 'patch', + WebSearch = 'web_search', + AskUser = 'ask_user', + ReportIntent = 'report_intent', +} + +/** Parameters for the `bash` / `powershell` shell tools. */ +interface ICopilotShellToolArgs { + command: string; + timeout?: number; +} + +/** Parameters for file tools (`view`, `edit`, `write`). */ +interface ICopilotFileToolArgs { + path: string; +} + +/** Parameters for the `grep` tool. */ +interface ICopilotGrepToolArgs { + pattern: string; + path?: string; + include?: string; +} + +/** Parameters for the `glob` tool. */ +interface ICopilotGlobToolArgs { + pattern: string; + path?: string; +} + +/** Set of tool names that perform file edits. */ +const EDIT_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.Edit, + CopilotToolName.Write, + CopilotToolName.Patch, +]); + +/** + * Returns true if the tool modifies files on disk. + */ +export function isEditTool(toolName: string): boolean { + return EDIT_TOOL_NAMES.has(toolName); +} + +/** + * Extracts the target file path from an edit tool's parameters, if available. + */ +export function getEditFilePath(parameters: unknown): string | undefined { + if (typeof parameters === 'string') { + try { + parameters = JSON.parse(parameters); + } catch { + return undefined; + } + } + + const args = parameters as ICopilotFileToolArgs | undefined; + return args?.path; +} + +/** Set of tool names that execute shell commands (bash or powershell). */ +const SHELL_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.Bash, + CopilotToolName.PowerShell, +]); + +/** + * Tools that should not be shown to the user. These are internal tools + * used by the CLI for its own purposes (e.g., reporting intent to the model). + */ +const HIDDEN_TOOL_NAMES: ReadonlySet = new Set([ + CopilotToolName.ReportIntent, +]); + +/** + * Returns true if the tool should be hidden from the UI. + */ +export function isHiddenTool(toolName: string): boolean { + return HIDDEN_TOOL_NAMES.has(toolName); +} + +// ============================================================================= +// Display helpers +// +// These functions translate Copilot CLI tool names and arguments into +// human-readable display strings. This logic lives here -- in the agent-host +// process -- so the IPC protocol stays agent-agnostic; the renderer never needs +// to know about specific tool names. +// ============================================================================= + +function truncate(text: string, maxLength: number): string { + return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; +} + +export function getToolDisplayName(toolName: string): string { + switch (toolName) { + case CopilotToolName.Bash: return localize('toolName.bash', "Bash"); + case CopilotToolName.PowerShell: return localize('toolName.powershell', "PowerShell"); + case CopilotToolName.ReadBash: + case CopilotToolName.ReadPowerShell: return localize('toolName.readShell', "Read Shell Output"); + case CopilotToolName.WriteBash: + case CopilotToolName.WritePowerShell: return localize('toolName.writeShell', "Write Shell Input"); + case CopilotToolName.BashShutdown: return localize('toolName.bashShutdown', "Stop Shell"); + case CopilotToolName.ListBash: + case CopilotToolName.ListPowerShell: return localize('toolName.listShells', "List Shells"); + case CopilotToolName.View: return localize('toolName.view', "View File"); + case CopilotToolName.Edit: return localize('toolName.edit', "Edit File"); + case CopilotToolName.Write: return localize('toolName.write', "Write File"); + case CopilotToolName.Grep: return localize('toolName.grep', "Search"); + case CopilotToolName.Glob: return localize('toolName.glob', "Find Files"); + case CopilotToolName.Patch: return localize('toolName.patch', "Patch"); + case CopilotToolName.WebSearch: return localize('toolName.webSearch', "Web Search"); + case CopilotToolName.AskUser: return localize('toolName.askUser', "Ask User"); + default: return toolName; + } +} + +export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): string { + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return localize('toolInvoke.shellCmd', "Running `{0}`", truncate(firstLine, 80)); + } + return localize('toolInvoke.shell', "Running {0} command", displayName); + } + + switch (toolName) { + case CopilotToolName.View: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolInvoke.viewFile', "Reading {0}", args.path); + } + return localize('toolInvoke.view', "Reading file"); + } + case CopilotToolName.Edit: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolInvoke.editFile', "Editing {0}", args.path); + } + return localize('toolInvoke.edit', "Editing file"); + } + case CopilotToolName.Write: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolInvoke.writeFile', "Writing to {0}", args.path); + } + return localize('toolInvoke.write', "Writing file"); + } + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + if (args?.pattern) { + return localize('toolInvoke.grepPattern', "Searching for `{0}`", truncate(args.pattern, 80)); + } + return localize('toolInvoke.grep', "Searching files"); + } + case CopilotToolName.Glob: { + const args = parameters as ICopilotGlobToolArgs | undefined; + if (args?.pattern) { + return localize('toolInvoke.globPattern', "Finding files matching `{0}`", truncate(args.pattern, 80)); + } + return localize('toolInvoke.glob', "Finding files"); + } + default: + return localize('toolInvoke.generic', "Using \"{0}\"", displayName); + } +} + +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean): string { + if (!success) { + return localize('toolComplete.failed', "\"{0}\" failed", displayName); + } + + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + if (args?.command) { + const firstLine = args.command.split('\n')[0]; + return localize('toolComplete.shellCmd', "Ran `{0}`", truncate(firstLine, 80)); + } + return localize('toolComplete.shell', "Ran {0} command", displayName); + } + + switch (toolName) { + case CopilotToolName.View: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolComplete.viewFile', "Read {0}", args.path); + } + return localize('toolComplete.view', "Read file"); + } + case CopilotToolName.Edit: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolComplete.editFile', "Edited {0}", args.path); + } + return localize('toolComplete.edit', "Edited file"); + } + case CopilotToolName.Write: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (args?.path) { + return localize('toolComplete.writeFile', "Wrote to {0}", args.path); + } + return localize('toolComplete.write', "Wrote file"); + } + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + if (args?.pattern) { + return localize('toolComplete.grepPattern', "Searched for `{0}`", truncate(args.pattern, 80)); + } + return localize('toolComplete.grep', "Searched files"); + } + case CopilotToolName.Glob: { + const args = parameters as ICopilotGlobToolArgs | undefined; + if (args?.pattern) { + return localize('toolComplete.globPattern', "Found files matching `{0}`", truncate(args.pattern, 80)); + } + return localize('toolComplete.glob', "Found files"); + } + default: + return localize('toolComplete.generic', "Used \"{0}\"", displayName); + } +} + +export function getToolInputString(toolName: string, parameters: Record | undefined, rawArguments: string | undefined): string | undefined { + if (!parameters && !rawArguments) { + return undefined; + } + + if (SHELL_TOOL_NAMES.has(toolName)) { + const args = parameters as ICopilotShellToolArgs | undefined; + return args?.command ?? rawArguments; + } + + switch (toolName) { + case CopilotToolName.Grep: { + const args = parameters as ICopilotGrepToolArgs | undefined; + return args?.pattern ?? rawArguments; + } + default: + // For other tools, show the formatted JSON arguments + if (parameters) { + try { + return JSON.stringify(parameters, null, 2); + } catch { + return rawArguments; + } + } + return rawArguments; + } +} + +/** + * Returns a rendering hint for the given tool. Currently only 'terminal' is + * supported, which tells the renderer to display the tool as a terminal command + * block. + */ +export function getToolKind(toolName: string): 'terminal' | undefined { + if (SHELL_TOOL_NAMES.has(toolName)) { + return 'terminal'; + } + return undefined; +} + +/** + * Returns the shell language identifier for syntax highlighting. + * Used when creating terminal tool-specific data for the renderer. + */ +export function getShellLanguage(toolName: string): string { + switch (toolName) { + case CopilotToolName.PowerShell: return 'powershell'; + default: return 'shellscript'; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts new file mode 100644 index 0000000000000..8ee6b5a089329 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; + +/** + * Tracks file edits made by tools in a session by snapshotting file content + * before and after each edit tool invocation. + */ +export class FileEditTracker { + + /** + * Pending edits keyed by file path. The `onPreToolUse` hook stores + * entries here; `completeEdit` pops them when the tool finishes. + */ + private readonly _pendingEdits = new Map }>(); + + /** + * Completed edits keyed by file path. The `onPostToolUse` hook stores + * entries here; `takeCompletedEdit` retrieves them synchronously from + * the `onToolComplete` handler. + */ + private readonly _completedEdits = new Map(); + + constructor( + private readonly _sessionId: string, + private readonly _sessionDataService: ISessionDataService, + private readonly _fileService: IFileService, + private readonly _logService: ILogService, + ) { } + + /** + * Call from the `onPreToolUse` hook before an edit tool runs. + * Snapshots the file's current content as the "before" state. + * The hook blocks the SDK until this returns, ensuring the snapshot + * captures pre-edit content. + * + * @param filePath - Absolute path of the file being edited. + */ + async trackEditStart(filePath: string): Promise { + const editKey = generateEditKey(); + const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); + const beforeUri = URI.joinPath(sessionDataDir, 'file-edits', editKey, 'before'); + + const snapshotDone = this._snapshotFile(filePath, beforeUri); + this._pendingEdits.set(filePath, { editKey, beforeUri, snapshotDone }); + await snapshotDone; + } + + /** + * Call from the `onPostToolUse` hook after an edit tool finishes. + * Stores the result for later synchronous retrieval via {@link takeCompletedEdit}. + * The `beforeURI` points to the stored snapshot; the `afterURI` is + * the real file path (the tool already modified it on disk). + * + * @param filePath - Absolute path of the file that was edited. + */ + async completeEdit(filePath: string): Promise { + const pending = this._pendingEdits.get(filePath); + if (!pending) { + return; + } + this._pendingEdits.delete(filePath); + await pending.snapshotDone; + + // Snapshot the after-content into session data so it remains + // stable even if the file is modified again later. + const sessionDataDir = this._sessionDataService.getSessionDataDirById(this._sessionId); + const afterUri = URI.joinPath(sessionDataDir, 'file-edits', pending.editKey, 'after'); + await this._snapshotFile(filePath, afterUri); + + this._completedEdits.set(filePath, { + type: ToolResultContentType.FileEdit, + beforeURI: pending.beforeUri.toString(), + afterURI: afterUri.toString(), + }); + } + + /** + * Synchronously retrieves and removes a completed edit for the given + * file path. Call from the `onToolComplete` handler to include the + * edit in the tool result without async work. + */ + takeCompletedEdit(filePath: string): IToolResultFileEditContent | undefined { + const edit = this._completedEdits.get(filePath); + if (edit) { + this._completedEdits.delete(filePath); + } + return edit; + } + + private async _snapshotFile(filePath: string, targetUri: URI): Promise { + try { + const content = await this._fileService.readFile(URI.file(filePath)); + await this._fileService.writeFile(targetUri, content.value); + } catch (err) { + this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err); + await this._fileService.writeFile(targetUri, VSBuffer.fromString('')).catch(() => { }); + } + } +} + +let _editKeyCounter = 0; +function generateEditKey(): string { + return `${Date.now()}-${_editKeyCounter++}`; +} diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts new file mode 100644 index 0000000000000..fec30ea5754b3 --- /dev/null +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { FileAccess, Schemas } from '../../../base/common/network.js'; +import { Client, IIPCOptions } from '../../../base/parts/ipc/node/ipc.cp.js'; +import { IEnvironmentService, INativeEnvironmentService } from '../../environment/common/environment.js'; +import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; + +/** + * Options for configuring the agent host WebSocket server in the child process. + * When set, the agent host exposes a WebSocket endpoint for external clients. + */ +export interface IAgentHostWebSocketConfig { + /** TCP port to listen on. Mutually exclusive with `socketPath`. */ + readonly port?: string; + /** Unix domain socket / named pipe path. Takes precedence over `port`. */ + readonly socketPath?: string; + /** Host/IP to bind to. */ + readonly host?: string; + /** Connection token value. When set, WebSocket clients must present this token. */ + readonly connectionToken?: string; +} + +/** + * Spawns the agent host as a Node child process (fallback when + * Electron utility process is unavailable, e.g. dev/test). + */ +export class NodeAgentHostStarter extends Disposable implements IAgentHostStarter { + + private _wsConfig: IAgentHostWebSocketConfig | undefined; + + private readonly _onRequestConnection = this._register(new Emitter()); + readonly onRequestConnection = this._onRequestConnection.event; + + constructor( + @IEnvironmentService private readonly _environmentService: INativeEnvironmentService + ) { + super(); + } + + /** + * Configures the child process to also start a WebSocket server. + * Must be called before {@link start}. Triggers eager process start + * via {@link onRequestConnection}. + */ + setWebSocketConfig(config: IAgentHostWebSocketConfig): void { + this._wsConfig = config; + // Signal the process manager to start immediately rather than + // waiting for a renderer window to connect. + this._onRequestConnection.fire(); + } + + start(): IAgentHostConnection { + const env: Record = { + VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true', + }; + + // Forward WebSocket server configuration to the child process via env vars + if (this._wsConfig) { + if (this._wsConfig.port) { + env['VSCODE_AGENT_HOST_PORT'] = this._wsConfig.port; + } + if (this._wsConfig.socketPath) { + env['VSCODE_AGENT_HOST_SOCKET_PATH'] = this._wsConfig.socketPath; + } + if (this._wsConfig.host) { + env['VSCODE_AGENT_HOST_HOST'] = this._wsConfig.host; + } + if (this._wsConfig.connectionToken) { + env['VSCODE_AGENT_HOST_CONNECTION_TOKEN'] = this._wsConfig.connectionToken; + } + } + + const opts: IIPCOptions = { + serverName: 'Agent Host', + args: ['--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath], + env, + }; + + const agentHostDebug = parseAgentHostDebugPort(this._environmentService.args, this._environmentService.isBuilt); + if (agentHostDebug) { + if (agentHostDebug.break && agentHostDebug.port) { + opts.debugBrk = agentHostDebug.port; + } else if (!agentHostDebug.break && agentHostDebug.port) { + opts.debug = agentHostDebug.port; + } + } + + const client = new Client(FileAccess.asFileUri('bootstrap-fork').fsPath, opts); + + const store = new DisposableStore(); + store.add(client); + + return { + client, + store, + onDidProcessExit: client.onDidProcessExit + }; + } +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts new file mode 100644 index 0000000000000..6e71a13fba789 --- /dev/null +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -0,0 +1,466 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import type { ICommandMap } from '../common/state/protocol/messages.js'; +import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; +import { isActionKnownToVersion, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; +import { + AHP_SESSION_NOT_FOUND, + AHP_UNSUPPORTED_PROTOCOL_VERSION, + isJsonRpcNotification, + isJsonRpcRequest, + JSON_RPC_INTERNAL_ERROR, + ProtocolError, + type IAhpServerNotification, + type IBrowseDirectoryResult, + type ICreateSessionParams, + type IFetchContentResult, + type IInitializeParams, + type IJsonRpcResponse, + type IReconnectParams, + type IStateSnapshot, +} from '../common/state/sessionProtocol.js'; +import { ROOT_STATE_URI, type ISessionSummary, type URI } from '../common/state/sessionState.js'; +import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; +import { SessionStateManager } from './sessionStateManager.js'; + +/** Default capacity of the server-side action replay buffer. */ +const REPLAY_BUFFER_CAPACITY = 1000; + +/** Build a JSON-RPC success response suitable for transport.send(). */ +function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, result }; +} + +/** Build a JSON-RPC error response suitable for transport.send(). */ +function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; +} + +/** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ +function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { + if (err instanceof ProtocolError) { + return jsonRpcError(id, err.code, err.message, err.data); + } + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); +} + +/** + * Methods handled by the request dispatcher. Excludes `initialize` and + * `reconnect` which are handled during the handshake phase. + */ +type RequestMethod = Exclude; + +/** + * Typed handler map: each key is a request method, each value is a handler + * that receives the correctly-typed params and must return the correctly-typed + * result. The compiler will error if a handler returns the wrong shape. + */ +type RequestHandlerMap = { + [M in RequestMethod]: (client: IConnectedClient, params: ICommandMap[M]['params']) => Promise; +}; + +/** + * Represents a connected protocol client with its subscription state. + */ +interface IConnectedClient { + readonly clientId: string; + readonly protocolVersion: number; + readonly transport: IProtocolTransport; + readonly subscriptions: Set; + readonly disposables: DisposableStore; +} + +/** + * Server-side handler that manages protocol connections, routes JSON-RPC + * messages to the state manager, and broadcasts actions/notifications + * to subscribed clients. + */ +export class ProtocolServerHandler extends Disposable { + + private readonly _clients = new Map(); + private readonly _replayBuffer: IActionEnvelope[] = []; + + private readonly _onDidChangeConnectionCount = this._register(new Emitter()); + + /** Fires with the current client count whenever a client connects or disconnects. */ + readonly onDidChangeConnectionCount = this._onDidChangeConnectionCount.event; + + constructor( + private readonly _stateManager: SessionStateManager, + private readonly _server: IProtocolServer, + private readonly _sideEffectHandler: IProtocolSideEffectHandler, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._register(this._server.onConnection(transport => { + this._handleNewConnection(transport); + })); + + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + this._replayBuffer.push(envelope); + if (this._replayBuffer.length > REPLAY_BUFFER_CAPACITY) { + this._replayBuffer.shift(); + } + this._broadcastAction(envelope); + })); + + this._register(this._stateManager.onDidEmitNotification(notification => { + this._broadcastNotification(notification); + })); + } + + // ---- Connection handling ------------------------------------------------- + + private _handleNewConnection(transport: IProtocolTransport): void { + const disposables = new DisposableStore(); + let client: IConnectedClient | undefined; + + disposables.add(transport.onMessage(msg => { + if (isJsonRpcRequest(msg)) { + this._logService.trace(`[ProtocolServer] request: method=${msg.method} id=${msg.id}`); + + // Handle initialize/reconnect as requests that set up the client + if (!client && msg.method === 'initialize') { + try { + const result = this._handleInitialize(msg.params, transport, disposables); + client = result.client; + transport.send(jsonRpcSuccess(msg.id, result.response)); + } catch (err) { + transport.send(jsonRpcErrorFrom(msg.id, err)); + } + return; + } + if (!client && msg.method === 'reconnect') { + try { + const result = this._handleReconnect(msg.params, transport, disposables); + client = result.client; + transport.send(jsonRpcSuccess(msg.id, result.response)); + } catch (err) { + transport.send(jsonRpcErrorFrom(msg.id, err)); + } + return; + } + + if (!client) { + return; + } + this._handleRequest(client, msg.method, msg.params, msg.id); + } else if (isJsonRpcNotification(msg)) { + this._logService.trace(`[ProtocolServer] notification: method=${msg.method}`); + // Notification — fire-and-forget + switch (msg.method) { + case 'unsubscribe': + if (client) { + client.subscriptions.delete(msg.params.resource); + } + break; + case 'dispatchAction': + if (client) { + this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); + const origin = { clientId: client.clientId, clientSeq: msg.params.clientSeq }; + const action = msg.params.action as ISessionAction; + this._stateManager.dispatchClientAction(action, origin); + this._sideEffectHandler.handleAction(action); + } + break; + } + } + // Responses from the client (if any) are ignored on the server side. + })); + + disposables.add(transport.onClose(() => { + if (client && this._clients.get(client.clientId) === client) { + this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`); + this._clients.delete(client.clientId); + this._onDidChangeConnectionCount.fire(this._clients.size); + } + disposables.dispose(); + })); + + disposables.add(transport); + } + + // ---- Handshake handlers ---------------------------------------------------- + + private _handleInitialize( + params: IInitializeParams, + transport: IProtocolTransport, + disposables: DisposableStore, + ): { client: IConnectedClient; response: unknown } { + this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, version=${params.protocolVersion}`); + + if (params.protocolVersion < MIN_PROTOCOL_VERSION) { + throw new ProtocolError( + AHP_UNSUPPORTED_PROTOCOL_VERSION, + `Client protocol version ${params.protocolVersion} is below minimum ${MIN_PROTOCOL_VERSION}`, + ); + } + + const client: IConnectedClient = { + clientId: params.clientId, + protocolVersion: params.protocolVersion, + transport, + subscriptions: new Set(), + disposables, + }; + this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); + + const snapshots: IStateSnapshot[] = []; + if (params.initialSubscriptions) { + for (const uri of params.initialSubscriptions) { + const snapshot = this._stateManager.getSnapshot(uri); + if (snapshot) { + snapshots.push(snapshot); + client.subscriptions.add(uri.toString()); + } + } + } + + return { + client, + response: { + protocolVersion: PROTOCOL_VERSION, + serverSeq: this._stateManager.serverSeq, + snapshots, + defaultDirectory: this._sideEffectHandler.getDefaultDirectory?.(), + }, + }; + } + + private _handleReconnect( + params: IReconnectParams, + transport: IProtocolTransport, + disposables: DisposableStore, + ): { client: IConnectedClient; response: unknown } { + this._logService.info(`[ProtocolServer] Reconnect: clientId=${params.clientId}, lastSeenSeq=${params.lastSeenServerSeq}`); + + const client: IConnectedClient = { + clientId: params.clientId, + protocolVersion: PROTOCOL_VERSION, + transport, + subscriptions: new Set(), + disposables, + }; + this._clients.set(params.clientId, client); + this._onDidChangeConnectionCount.fire(this._clients.size); + + const oldestBuffered = this._replayBuffer.length > 0 ? this._replayBuffer[0].serverSeq : this._stateManager.serverSeq; + const canReplay = params.lastSeenServerSeq >= oldestBuffered; + + if (canReplay) { + const actions: IActionEnvelope[] = []; + for (const sub of params.subscriptions) { + client.subscriptions.add(sub.toString()); + } + for (const envelope of this._replayBuffer) { + if (envelope.serverSeq > params.lastSeenServerSeq) { + if (this._isRelevantToClient(client, envelope)) { + actions.push(envelope); + } + } + } + return { client, response: { type: 'replay', actions } }; + } else { + const snapshots: IStateSnapshot[] = []; + for (const sub of params.subscriptions) { + const snapshot = this._stateManager.getSnapshot(sub); + if (snapshot) { + snapshots.push(snapshot); + client.subscriptions.add(sub); + } + } + return { client, response: { type: 'snapshot', snapshots } }; + } + } + + // ---- Requests (expect a response) --------------------------------------- + + /** + * Methods handled by the request dispatcher (excludes initialize/reconnect + * which are handled during the handshake phase). + */ + private readonly _requestHandlers: RequestHandlerMap = { + subscribe: async (client, params) => { + let snapshot = this._stateManager.getSnapshot(params.resource); + if (!snapshot) { + // Session may exist on the agent backend but not in the + // current state manager (e.g. from a previous server + // lifetime). Try to restore it. + await this._sideEffectHandler.handleRestoreSession(params.resource); + snapshot = this._stateManager.getSnapshot(params.resource); + } + if (!snapshot) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource not found: ${params.resource}`); + } + client.subscriptions.add(params.resource); + return { snapshot }; + }, + createSession: async (_client, params) => { + await this._sideEffectHandler.handleCreateSession(params); + return null; + }, + disposeSession: async (_client, params) => { + this._sideEffectHandler.handleDisposeSession(params.session); + return null; + }, + listSessions: async () => { + const items = await this._sideEffectHandler.handleListSessions(); + return { items }; + }, + fetchTurns: async (_client, params) => { + const state = this._stateManager.getSessionState(params.session); + if (!state) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${params.session}`); + } + const turns = state.turns; + const limit = Math.min(params.limit ?? 50, 100); + + let endIndex = turns.length; + if (params.before) { + const idx = turns.findIndex(t => t.id === params.before); + if (idx !== -1) { + endIndex = idx; + } + } + + const startIndex = Math.max(0, endIndex - limit); + return { + turns: turns.slice(startIndex, endIndex), + hasMore: startIndex > 0, + }; + }, + browseDirectory: async (_client, params) => { + return this._sideEffectHandler.handleBrowseDirectory(params.uri); + }, + fetchContent: async (_client, params) => { + return this._sideEffectHandler.handleFetchContent(params.uri); + }, + }; + + private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { + const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; + if (handler) { + (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); + return; + } + + // VS Code extension methods (not in the typed protocol maps yet) + const extensionResult = this._handleExtensionRequest(method, params); + if (extensionResult) { + extensionResult.then(result => { + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); + return; + } + + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + } + + /** + * Handle VS Code extension methods that are not yet part of the typed + * protocol. Returns a Promise if the method was recognized, undefined + * otherwise. + */ + private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + switch (method) { + case 'getResourceMetadata': + return Promise.resolve(this._sideEffectHandler.handleGetResourceMetadata()); + case 'authenticate': + return this._sideEffectHandler.handleAuthenticate(params as IAuthenticateParams); + case 'refreshModels': + return this._sideEffectHandler.handleRefreshModels?.() ?? Promise.resolve(null); + case 'listAgents': + return Promise.resolve(this._sideEffectHandler.handleListAgents?.() ?? []); + case 'shutdown': + return this._sideEffectHandler.handleShutdown?.() ?? Promise.resolve(null); + default: + return undefined; + } + } + + // ---- Broadcasting ------------------------------------------------------- + + private _broadcastAction(envelope: IActionEnvelope): void { + this._logService.trace(`[ProtocolServer] Broadcasting action: ${envelope.action.type}`); + const msg: IAhpServerNotification<'action'> = { jsonrpc: '2.0', method: 'action', params: envelope }; + for (const client of this._clients.values()) { + if (this._isRelevantToClient(client, envelope)) { + client.transport.send(msg); + } + } + } + + private _broadcastNotification(notification: INotification): void { + const msg: IAhpServerNotification<'notification'> = { jsonrpc: '2.0', method: 'notification', params: { notification } }; + for (const client of this._clients.values()) { + client.transport.send(msg); + } + } + + private _isRelevantToClient(client: IConnectedClient, envelope: IActionEnvelope): boolean { + const action = envelope.action; + if (!isActionKnownToVersion(action, client.protocolVersion)) { + return false; + } + if (action.type.startsWith('root/')) { + return client.subscriptions.has(ROOT_STATE_URI); + } + if (isSessionAction(action)) { + return client.subscriptions.has(action.session); + } + return false; + } + + override dispose(): void { + for (const client of this._clients.values()) { + client.disposables.dispose(); + } + this._clients.clear(); + this._replayBuffer.length = 0; + super.dispose(); + } +} + +/** + * Interface for side effects that the protocol server delegates to. + * These are operations that involve I/O, agent backends, etc. + */ +export interface IProtocolSideEffectHandler { + handleAction(action: ISessionAction): void; + handleCreateSession(command: ICreateSessionParams): Promise; + handleDisposeSession(session: URI): void; + handleListSessions(): Promise; + /** Restore a session from a previous server lifetime into the state manager. */ + handleRestoreSession(session: URI): Promise; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; + handleBrowseDirectory(uri: URI): Promise; + handleFetchContent(uri: URI): Promise; + /** Returns the server's default browsing directory, if available. */ + getDefaultDirectory?(): URI; + /** Refresh models from all providers (VS Code extension method). */ + handleRefreshModels?(): Promise; + /** List agent descriptors (VS Code extension method). */ + handleListAgents?(): IAgentDescriptor[]; + /** Shut down all providers (VS Code extension method). */ + handleShutdown?(): Promise; +} diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts new file mode 100644 index 0000000000000..5bbcb89dc076b --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; + +/** + * Implementation of {@link ISessionDataService} that stores per-session data + * under `{userDataPath}/agentSessionData/{sessionId}/`. + */ +export class SessionDataService implements ISessionDataService { + declare readonly _serviceBrand: undefined; + + private readonly _basePath: URI; + + constructor( + userDataPath: URI, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + ) { + this._basePath = URI.joinPath(userDataPath, 'agentSessionData'); + } + + getSessionDataDir(session: URI): URI { + return this.getSessionDataDirById(AgentSession.id(session)); + } + + getSessionDataDirById(sessionId: string): URI { + const sanitized = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-'); + return URI.joinPath(this._basePath, sanitized); + } + + async deleteSessionData(session: URI): Promise { + const dir = this.getSessionDataDir(session); + try { + if (await this._fileService.exists(dir)) { + await this._fileService.del(dir, { recursive: true }); + this._logService.trace(`[SessionDataService] Deleted session data: ${dir.toString()}`); + } + } catch (err) { + this._logService.warn(`[SessionDataService] Failed to delete session data: ${dir.toString()}`, err); + } + } + + async cleanupOrphanedData(knownSessionIds: Set): Promise { + try { + const exists = await this._fileService.exists(this._basePath); + if (!exists) { + return; + } + + const stat = await this._fileService.resolve(this._basePath); + if (!stat.children) { + return; + } + + const deletions: Promise[] = []; + for (const child of stat.children) { + if (!child.isDirectory) { + continue; + } + const name = child.name; + if (!knownSessionIds.has(name)) { + this._logService.trace(`[SessionDataService] Cleaning up orphaned session data: ${name}`); + deletions.push( + this._fileService.del(child.resource, { recursive: true }).catch(err => { + this._logService.warn(`[SessionDataService] Failed to clean up orphaned data: ${name}`, err); + }) + ); + } + } + + await Promise.all(deletions); + } catch (err) { + this._logService.warn('[SessionDataService] Failed to run orphan cleanup', err); + } + } +} diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts new file mode 100644 index 0000000000000..531c18dd6bc32 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -0,0 +1,249 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { ActionType, NotificationType, IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; +import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; +import { createRootState, createSessionState, SessionLifecycle, type IRootState, type ISessionState, type ISessionSummary, type ITurn, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; + +/** + * Server-side state manager for the sessions process protocol. + * + * Maintains the authoritative state tree (root + per-session), applies actions + * through pure reducers, assigns monotonic sequence numbers, and emits + * {@link IActionEnvelope}s for subscribed clients. + */ +export class SessionStateManager extends Disposable { + + private _serverSeq = 0; + + private _rootState: IRootState; + private readonly _sessionStates = new Map(); + + /** Tracks which session URI each active turn belongs to, keyed by turnId. */ + private readonly _activeTurnToSession = new Map(); + + private readonly _onDidEmitEnvelope = this._register(new Emitter()); + readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; + + private readonly _onDidEmitNotification = this._register(new Emitter()); + readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._rootState = createRootState(); + } + private readonly _log = (msg: string) => this._logService.warn(`[SessionStateManager] ${msg}`); + + get hasActiveSessions(): boolean { + return this._activeTurnToSession.size > 0; + } + + // ---- State accessors ---------------------------------------------------- + + get rootState(): IRootState { + return this._rootState; + } + + getSessionState(session: URI): ISessionState | undefined { + return this._sessionStates.get(session); + } + + get serverSeq(): number { + return this._serverSeq; + } + + // ---- Snapshots ---------------------------------------------------------- + + /** + * Returns a state snapshot for a given resource URI. + * The `fromSeq` in the snapshot is the current serverSeq at snapshot time; + * the client should process subsequent envelopes with serverSeq > fromSeq. + */ + getSnapshot(resource: URI): IStateSnapshot | undefined { + if (resource === ROOT_STATE_URI) { + return { + resource, + state: this._rootState, + fromSeq: this._serverSeq, + }; + } + + const sessionState = this._sessionStates.get(resource); + if (!sessionState) { + return undefined; + } + + return { + resource, + state: sessionState, + fromSeq: this._serverSeq, + }; + } + + // ---- Session lifecycle -------------------------------------------------- + + /** + * Creates a new session in state with `lifecycle: 'creating'`. + * Returns the initial session state. + */ + createSession(summary: ISessionSummary): ISessionState { + const key = summary.resource; + if (this._sessionStates.has(key)) { + this._logService.warn(`[SessionStateManager] Session already exists: ${key}`); + return this._sessionStates.get(key)!; + } + + const state = createSessionState(summary); + this._sessionStates.set(key, state); + + this._logService.trace(`[SessionStateManager] Created session: ${key}`); + + this._onDidEmitNotification.fire({ + type: NotificationType.SessionAdded, + summary, + }); + + return state; + } + + /** + * Restores a session from a previous server lifetime into the state manager + * with pre-populated turns. The session is created in `ready` lifecycle + * state since it already exists on the backend. + * + * Unlike {@link createSession}, this does NOT emit a `sessionAdded` + * notification because the session is already known to clients via + * `listSessions`. + */ + restoreSession(summary: ISessionSummary, turns: ITurn[]): ISessionState { + const key = summary.resource; + if (this._sessionStates.has(key)) { + this._logService.warn(`[SessionStateManager] Session already exists (restore): ${key}`); + return this._sessionStates.get(key)!; + } + + const state: ISessionState = { + ...createSessionState(summary), + lifecycle: SessionLifecycle.Ready, + turns, + }; + this._sessionStates.set(key, state); + + this._logService.trace(`[SessionStateManager] Restored session: ${key} (${turns.length} turns)`); + + return state; + } + + /** + * Removes a session from state and emits a sessionRemoved notification. + */ + removeSession(session: URI): void { + const state = this._sessionStates.get(session); + if (!state) { + return; + } + + // Clean up active turn tracking + if (state.activeTurn) { + this._activeTurnToSession.delete(state.activeTurn.id); + } + + this._sessionStates.delete(session); + this._logService.trace(`[SessionStateManager] Removed session: ${session}`); + + this._onDidEmitNotification.fire({ + type: NotificationType.SessionRemoved, + session, + }); + } + + // ---- Turn tracking ------------------------------------------------------ + + /** + * Registers a mapping from turnId to session URI so that incoming + * provider events (which carry only session URI) can be associated + * with the correct active turn. + */ + getActiveTurnId(session: URI): string | undefined { + const state = this._sessionStates.get(session); + return state?.activeTurn?.id; + } + + // ---- Action dispatch ---------------------------------------------------- + + /** + * Dispatch a server-originated action (from the agent backend). + * The action is applied to state via the reducer and emitted as an + * envelope with no origin (server-produced). + */ + dispatchServerAction(action: IStateAction): void { + this._applyAndEmit(action, undefined); + } + + /** + * Dispatch a client-originated action (write-ahead from a renderer). + * The action is applied to state and emitted with the client's origin + * so the originating client can reconcile. + */ + dispatchClientAction(action: ISessionAction, origin: IActionOrigin): unknown { + return this._applyAndEmit(action, origin); + } + + // ---- Internal ----------------------------------------------------------- + + private _applyAndEmit(action: IStateAction, origin: IActionOrigin | undefined): unknown { + let resultingState: unknown = undefined; + // Apply to state + if (isRootAction(action)) { + this._rootState = rootReducer(this._rootState, action as IRootAction, this._log); + resultingState = this._rootState; + } + + if (isSessionAction(action)) { + const sessionAction = action as ISessionAction; + const key = sessionAction.session; + const state = this._sessionStates.get(key); + if (state) { + const newState = sessionReducer(state, sessionAction, this._log); + this._sessionStates.set(key, newState); + + // Track active turn for turn lifecycle + if (sessionAction.type === ActionType.SessionTurnStarted) { + this._activeTurnToSession.set(sessionAction.turnId, key); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); + } else if ( + sessionAction.type === ActionType.SessionTurnComplete || + sessionAction.type === ActionType.SessionTurnCancelled || + sessionAction.type === ActionType.SessionError + ) { + this._activeTurnToSession.delete(sessionAction.turnId); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); + } + + resultingState = newState; + } else { + this._logService.warn(`[SessionStateManager] Action for unknown session: ${key}, type=${action.type}`); + } + } + + // Emit envelope + const envelope: IActionEnvelope = { + action, + serverSeq: ++this._serverSeq, + origin, + }; + + this._logService.trace(`[SessionStateManager] Emitting envelope: seq=${envelope.serverSeq}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); + this._onDidEmitEnvelope.fire(envelope); + + return resultingState; + } +} diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts new file mode 100644 index 0000000000000..450204d5bb88b --- /dev/null +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// WebSocket transport for the sessions process protocol. +// Uses JSON serialization with URI revival for cross-process communication. + +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { connectionTokenQueryName } from '../../../base/common/network.js'; +import { ILogService } from '../../log/common/log.js'; +import { JSON_RPC_PARSE_ERROR, type IAhpServerNotification, type IJsonRpcResponse, type IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; + +/** + * Options for creating a {@link WebSocketProtocolServer}. + * Provide either `port`+`host` or `socketPath`, not both. + */ +export interface IWebSocketServerOptions { + /** TCP port to listen on. Ignored when {@link socketPath} is set. */ + readonly port?: number; + /** Host/IP to bind to. Defaults to `'127.0.0.1'`. */ + readonly host?: string; + /** Unix domain socket / Windows named pipe path. Takes precedence over port. */ + readonly socketPath?: string; + /** + * Optional token validator. When provided, WebSocket upgrade requests + * must include a valid token in the `tkn` query parameter. + */ + readonly connectionTokenValidate?: (token: unknown) => boolean; +} + +// ---- Per-connection transport ----------------------------------------------- + +/** + * Wraps a single WebSocket connection as an {@link IProtocolTransport}. + * Messages are serialized as JSON with URI revival. + */ +export class WebSocketProtocolTransport extends Disposable implements IProtocolTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + constructor( + private readonly _ws: import('ws').WebSocket, + private readonly _WebSocket: typeof import('ws').WebSocket, + ) { + super(); + + this._ws.on('message', (data: Buffer | string) => { + try { + const text = typeof data === 'string' ? data : data.toString('utf-8'); + const message = JSON.parse(text) as IProtocolMessage; + this._onMessage.fire(message); + } catch { + this.send({ jsonrpc: '2.0', id: null!, error: { code: JSON_RPC_PARSE_ERROR, message: 'Parse error' } }); + } + }); + + this._ws.on('close', () => { + this._onClose.fire(); + }); + + this._ws.on('error', () => { + // Error always precedes close — closing is handled in the close handler. + this._onClose.fire(); + }); + } + + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + if (this._ws.readyState === this._WebSocket.OPEN) { + this._ws.send(JSON.stringify(message)); + } + } + + override dispose(): void { + this._ws.close(); + super.dispose(); + } +} + +// ---- Server ----------------------------------------------------------------- + +/** + * WebSocket server that accepts client connections and wraps each one + * as an {@link IProtocolTransport}. + * + * Use the static {@link create} method to construct — it dynamically imports + * `ws` and `http`/`url` so the modules are only loaded when needed. + */ +export class WebSocketProtocolServer extends Disposable implements IProtocolServer { + + private readonly _wss: import('ws').WebSocketServer; + private readonly _httpServer: import('http').Server | undefined; + private readonly _WebSocket: typeof import('ws').WebSocket; + + private readonly _onConnection = this._register(new Emitter()); + readonly onConnection = this._onConnection.event; + + get address(): string | undefined { + const addr = this._wss.address(); + if (!addr || typeof addr === 'string') { + return addr ?? undefined; + } + return `${addr.address}:${addr.port}`; + } + + /** + * Creates a new WebSocket protocol server. Dynamically imports `ws`, + * `http`, and `url` so callers don't pay the cost when unused. + */ + static async create( + options: IWebSocketServerOptions | number, + logService: ILogService, + ): Promise { + const [ws, http, url] = await Promise.all([ + import('ws'), + import('http'), + import('url'), + ]); + return new WebSocketProtocolServer(options, logService, ws, http, url); + } + + private constructor( + options: IWebSocketServerOptions | number, + private readonly _logService: ILogService, + ws: typeof import('ws'), + http: typeof import('http'), + url: typeof import('url'), + ) { + super(); + + this._WebSocket = ws.WebSocket; + + // Backwards compat: accept a plain port number + const opts: IWebSocketServerOptions = typeof options === 'number' ? { port: options } : options; + const host = opts.host ?? '127.0.0.1'; + + const verifyClient = opts.connectionTokenValidate + ? (info: { req: import('http').IncomingMessage }, cb: (res: boolean, code?: number, message?: string) => void) => { + const parsedUrl = url.parse(info.req.url ?? '', true); + const token = parsedUrl.query[connectionTokenQueryName]; + if (!opts.connectionTokenValidate!(token)) { + this._logService.warn('[WebSocketProtocol] Connection rejected: invalid connection token'); + cb(false, 403, 'Forbidden'); + return; + } + cb(true); + } + : undefined; + + if (opts.socketPath) { + // For socket paths, create an HTTP server listening on the path + // and attach the WebSocket server to it. + this._httpServer = http.createServer(); + this._wss = new ws.WebSocketServer({ server: this._httpServer, verifyClient }); + this._httpServer.listen(opts.socketPath, () => { + this._logService.info(`[WebSocketProtocol] Server listening on socket ${opts.socketPath}`); + }); + } else { + this._wss = new ws.WebSocketServer({ port: opts.port, host, verifyClient }); + this._logService.info(`[WebSocketProtocol] Server listening on ${host}:${opts.port}`); + } + + this._wss.on('connection', (wsConn) => { + this._logService.trace('[WebSocketProtocol] New client connection'); + const transport = new WebSocketProtocolTransport(wsConn, this._WebSocket); + this._onConnection.fire(transport); + }); + + this._wss.on('error', (err) => { + this._logService.error('[WebSocketProtocol] Server error', err); + }); + } + + override dispose(): void { + this._wss.close(); + this._httpServer?.close(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/protocol.md b/src/vs/platform/agentHost/protocol.md new file mode 100644 index 0000000000000..f089aba1bd9d0 --- /dev/null +++ b/src/vs/platform/agentHost/protocol.md @@ -0,0 +1,529 @@ +# Sessions process protocol + +> **Keep this document in sync with the code.** Changes to the state model, action types, protocol messages, or versioning strategy must be reflected here. Implementation lives in `common/state/`. + +> **Pre-production.** This protocol is under active development and is not shipped yet. Breaking changes to wire types, actions, and state shapes are fine — do not worry about backward compatibility until the protocol is in production. The versioning machinery exists for future use. + +For process architecture and IPC details, see [architecture.md](architecture.md). For design decisions, see [design.md](design.md). + +## Goal + +The sessions process is a portable, standalone server that multiple clients can connect to. Clients see a synchronized view of sessions and can send commands that are reflected back as state-changing actions. The protocol is designed around four requirements: + +1. **Synchronized multi-client state** — an immutable, redux-like state tree mutated exclusively by actions flowing through pure reducers. +2. **Lazy loading** — clients subscribe to state by URI and load data on demand. The session list is fetched imperatively. Large content (images, long tool outputs) is stored by reference and fetched separately. +3. **Write-ahead with reconciliation** — clients optimistically apply their own actions locally, then reconcile when the server echoes them back alongside any concurrent actions from other clients or the server itself. +4. **Forward-compatible versioning** — newer clients can connect to older servers. A single protocol version number maps to a capabilities object; clients check capabilities before using features. + +## Protocol development checklist + +Use this checklist when adding a new action, command, state field, or notification to the protocol. + +### Adding a new action type + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts` that exercises the action end-to-end through the WebSocket server. The test should fail until the implementation is complete. +2. **Add mock agent support** if the test needs a new prompt/behavior in `mockAgent.ts`. +3. **Define the action interface** in `sessionActions.ts`. Extend `ISessionActionBase` (for session-scoped) or define a standalone root action. Add it to the `ISessionAction` or `IRootAction` union. +4. **Add a reducer case** in `sessionReducers.ts`. The switch must remain exhaustive — the compiler will error if a case is missing. +5. **Add a v1 wire type** in `versions/v1.ts`. Mirror the action interface shape. Add it to the `IV1_SessionAction` or `IV1_RootAction` union. +6. **Register in `versionRegistry.ts`**: + - Import the new `IV1_*` type. + - Add an `AssertCompatible` check. + - Add the type to the `ISessionAction_v1` union. + - Add the type string to the suppress-warnings `void` expression. + - Add an entry to `ACTION_INTRODUCED_IN` (compiler enforces this). +7. **Update `protocol.md`** (this file) — add the action to the Actions table. +8. **Verify the E2E test passes.** + +### Adding a new command + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`. The test should fail until the implementation is complete. +2. **Define the request params and result interfaces** in `sessionProtocol.ts`. +3. **Handle it in `protocolServerHandler.ts`** `_handleRequestAsync()`. The method returns the result; the caller wraps it in a JSON-RPC response or error automatically. +4. **Add the side-effect** in `IProtocolSideEffectHandler` if the command requires I/O or agent interaction. Implement it in `agentHostServerMain.ts`. +5. **Update `protocol.md`** — add the command to the Commands table. +6. **Verify the E2E test passes.** + +### Adding a new state field + +1. **Add the field** to the relevant interface in `sessionState.ts` (e.g. `ISessionSummary`, `IActiveTurn`, `ITurn`). +2. **Update the factory** (`createSessionState()`, `createActiveTurn()`) to initialize the field. +3. **Add to the v1 wire type** in `versions/v1.ts`. Optional fields are safe; required fields break the bidirectional `AssertCompatible` check (intentionally — add as optional or bump the protocol version). +4. **Update reducers** in `sessionReducers.ts` if the field needs to be mutated by actions. +5. **Update `finalizeTurn()`** if the field lives on `IActiveTurn` and should transfer to `ITurn` on completion. + +### Adding a new notification + +1. **Write an E2E test first** in `protocolWebSocket.integrationTest.ts`. +2. **Define the notification interface** in `sessionActions.ts`. Add it to the `INotification` union. +3. **Add to `NOTIFICATION_INTRODUCED_IN`** in `versionRegistry.ts`. +4. **Emit it** from `SessionStateManager` or the relevant server-side code. +5. **Verify the E2E test passes.** + +### Adding mock agent support (for testing) + +1. **Add a prompt case** in `mockAgent.ts` `sendMessage()` to trigger the behavior. +2. **Fire the corresponding `IAgentProgressEvent`** via `_fireSequence()` or manually through `_onDidSessionProgress`. + + +## URI-based subscriptions + +All state is identified by URIs. Clients subscribe to a URI to receive its current state snapshot and subsequent action updates. This is the single universal mechanism for state synchronization: + +- **Root state** (`agenthost:root`) — always-present global state (agents and their models). Clients subscribe to this on connect. +- **Session state** (`copilot:/`, etc.) — per-session state loaded on demand. Clients subscribe when opening a session. + +The `subscribe(uri)` / `unsubscribe(uri)` mechanism works identically for all resource types. + +## State model + +### Root state + +Subscribable at `agenthost:root`. Contains global, lightweight data that all clients need. **Does not contain the session list** — that is fetched imperatively via RPC (see Commands). + +``` +RootState { + agents: AgentInfo[] +} +``` + +Each `AgentInfo` includes the models available for that agent: + +``` +AgentInfo { + provider: string + displayName: string + description: string + models: ModelInfo[] +} +``` + +### Session state + +Subscribable at the session's URI (e.g. `copilot:/`). Contains the full state for a single session. + +``` +SessionState { + summary: SessionSummary + lifecycle: 'creating' | 'ready' | 'creationFailed' + creationError?: ErrorInfo + turns: Turn[] + activeTurn: ActiveTurn | undefined +} +``` + +`lifecycle` tracks the asynchronous creation process. When a client creates a session, it picks a URI, sends the command, and subscribes immediately. The initial snapshot has `lifecycle: 'creating'`. The server asynchronously initializes the backend and dispatches `session/ready` or `session/creationFailed`. + +``` +Turn { + id: string + userMessage: UserMessage + responseParts: ResponsePart[] + toolCalls: CompletedToolCall[] + usage: UsageInfo | undefined + state: 'complete' | 'cancelled' | 'error' +} + +ActiveTurn { + id: string + userMessage: UserMessage + streamingText: string + responseParts: ResponsePart[] + toolCalls: Record + pendingPermissions: Record + reasoning: string + usage: UsageInfo | undefined +} +``` + +### Session list + +The session list can be arbitrarily large and is **not** part of the state tree. Instead: +- Clients fetch the list imperatively via `listSessions()` RPC. +- The server sends lightweight **notifications** (`sessionAdded`, `sessionRemoved`) so connected clients can update a local cache without re-fetching. + +Notifications are ephemeral — not processed by reducers, not stored in state, not replayed on reconnect. On reconnect, clients re-fetch the list. + +### Content references + +Large content is **not** inlined in state. A `ContentRef` placeholder is used instead: + +``` +ContentRef { + uri: string // scheme://sessionId/contentId + sizeHint?: number + mimeType?: string +} +``` + +Clients fetch content separately via `fetchContent(uri)`. This keeps the state tree small and serializable. + +## Actions + +Actions are the sole mutation mechanism for subscribable state. They form a discriminated union keyed by `type`. Every action is wrapped in an `ActionEnvelope` for sequencing and origin tracking. + +### Action envelope + +``` +ActionEnvelope { + action: Action + serverSeq: number // monotonic, assigned by server + origin: { clientId: string, clientSeq: number } | undefined // undefined = server-originated + rejectionReason?: string // present when the server rejected the action +} +``` + +### Root actions + +These mutate the root state. **All root actions are server-only** — clients observe them but cannot produce them. + +| Type | Payload | When | +|---|---|---| +| `root/agentsChanged` | `AgentInfo[]` | Available agent backends or their models changed | + +### Session actions + +All scoped to a session URI. Some are server-only (produced by the agent backend), others can be dispatched directly by clients. + +When a client dispatches an action, the server applies it to the state and also reacts to it as a side effect (e.g., `session/turnStarted` triggers agent processing, `session/turnCancelled` aborts it). This avoids a separate command→action translation layer for the common interactive cases. + +| Type | Payload | Client-dispatchable? | When | +|---|---|---|---| +| `session/ready` | — | No | Session backend initialized successfully | +| `session/creationFailed` | `ErrorInfo` | No | Session backend failed to initialize | +| `session/turnStarted` | `turnId, UserMessage` | Yes | User sent a message; server starts processing | +| `session/delta` | `turnId, content` | No | Streaming text chunk from assistant | +| `session/responsePart` | `turnId, ResponsePart` | No | Structured content appended | +| `session/toolStart` | `turnId, ToolCallState` | No | Tool execution began | +| `session/toolComplete` | `turnId, toolCallId, ToolCallResult` | No | Tool execution finished | +| `session/permissionRequest` | `turnId, PermissionRequest` | No | Permission needed from user | +| `session/permissionResolved` | `turnId, requestId, approved` | Yes | Permission granted or denied | +| `session/turnComplete` | `turnId` | No | Turn finished (assistant idle) | +| `session/turnCancelled` | `turnId` | Yes | Turn was aborted; server stops processing | +| `session/error` | `turnId, ErrorInfo` | No | Error during turn processing | +| `session/titleChanged` | `title` | No | Session title updated | +| `session/usage` | `turnId, UsageInfo` | No | Token usage report | +| `session/reasoning` | `turnId, content` | No | Reasoning/thinking text | +| `session/modelChanged` | `model` | Yes | Model changed for this session | + +### Notifications + +Notifications are ephemeral broadcasts that are **not** part of the state tree. They are not processed by reducers and are not replayed on reconnect. + +| Type | Payload | When | +|---|---|---| +| `notify/sessionAdded` | `SessionSummary` | A new session was created | +| `notify/sessionRemoved` | session `URI` | A session was disposed | + +Clients use notifications to maintain a local session list cache. On reconnect, clients should re-fetch via `listSessions()` rather than relying on replayed notifications. + +## Commands and client-dispatched actions + +Clients interact with the server in two ways: + +1. **Dispatching actions** — the client sends an action directly (e.g., `session/turnStarted`, `session/turnCancelled`). The server applies it to state and reacts with side effects. These are write-ahead: the client applies them optimistically. +2. **Sending commands** — imperative RPCs for operations that don't map to a single state action (session creation, fetching data, etc.). + +### Client-dispatched actions + +| Action | Server-side effect | +|---|---| +| `session/turnStarted` | Begins agent processing for the new turn | +| `session/permissionResolved` | Unblocks the pending tool execution | +| `session/turnCancelled` | Aborts the in-progress turn | + +### Commands + +| Command | Effect | +|---|---| +| `createSession(uri, config)` | Server creates session, client subscribes to URI | +| `disposeSession(session)` | Server disposes session, broadcasts `sessionRemoved` notification | +| `listSessions(filter?)` | Returns `SessionSummary[]` | +| `fetchContent(uri)` | Returns content bytes | +| `fetchTurns(session, range)` | Returns historical turns | +| `browseDirectory(uri)` | Lists directory entries at a file URI on the server's filesystem | + +`browseDirectory(uri)` succeeds only if the target exists and is a directory. If the target does not exist, is not a directory, or cannot be accessed, the server MUST return a JSON-RPC error. + +### Session creation flow + +1. Client picks a session URI (e.g. `copilot:/`) +2. Client sends `createSession(uri, config)` command +3. Client sends `subscribe(uri)` (can be batched with the command) +4. Server creates the session in state with `lifecycle: 'creating'` and sends the subscription snapshot +5. Server asynchronously initializes the agent backend +6. On success: server dispatches `session/ready` action +7. On failure: server dispatches `session/creationFailed` action with error details +8. Server broadcasts `notify/sessionAdded` to all clients + +## Client-server protocol + +The protocol uses **JSON-RPC 2.0** framing over the transport (WebSocket, MessagePort, etc.). + +### Message categories + +- **Client → Server notifications** (fire-and-forget): `unsubscribe`, `dispatchAction` +- **Client → Server requests** (expect a correlated response): `initialize`, `reconnect`, `subscribe`, `createSession`, `disposeSession`, `listSessions`, `fetchTurns`, `fetchContent`, `browseDirectory` +- **Server → Client notifications** (pushed): `action`, `notification` +- **Server → Client responses** (correlated to requests by `id`): success result or JSON-RPC error + +### Connection handshake + +`initialize` is a JSON-RPC **request** — the server MUST respond with a result or error: + +``` +1. Client → Server: { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { protocolVersion, clientId, initialSubscriptions? } } +2. Server → Client: { "jsonrpc": "2.0", "id": 1, "result": { protocolVersion, serverSeq, snapshots[], defaultDirectory? } } +``` + +`initialSubscriptions` allows the client to subscribe to root state (and any previously-open sessions on reconnect) in the same round-trip as the handshake. The server returns snapshots for each in the response. + +### URI subscription + +`subscribe` is a JSON-RPC **request** — the client receives the snapshot as the response result: + +``` +Client → Server: { "jsonrpc": "2.0", "id": 1, "method": "subscribe", "params": { "resource": "copilot:/session-1" } } +Server → Client: { "jsonrpc": "2.0", "id": 1, "result": { "resource": ..., "state": ..., "fromSeq": 5 } } +``` + +After subscribing, the client receives all actions scoped to that URI with `serverSeq > fromSeq`. Multiple concurrent subscriptions are supported. + +`unsubscribe` is a notification (no response needed): + +``` +Client → Server: { "jsonrpc": "2.0", "method": "unsubscribe", "params": { "resource": "copilot:/session-1" } } +``` + +### Action delivery + +The server broadcasts action envelopes as JSON-RPC notifications: + +``` +Server → Client: { "jsonrpc": "2.0", "method": "action", "params": { "envelope": { action, serverSeq, origin } } } +``` + +- Root actions go to all clients subscribed to root state. +- Session actions go to all clients subscribed to that session's URI. + +Protocol notifications (sessionAdded/sessionRemoved) are broadcast similarly: + +``` +Server → Client: { "jsonrpc": "2.0", "method": "notification", "params": { "notification": { type, ... } } } +``` + +### Commands as JSON-RPC requests + +Commands are JSON-RPC requests. The server returns a result or a JSON-RPC error: + +``` +Client → Server: { "jsonrpc": "2.0", "id": 2, "method": "createSession", "params": { session, provider?, model? } } +Server → Client: { "jsonrpc": "2.0", "id": 2, "result": null } +``` + +On failure: + +``` +Server → Client: { "jsonrpc": "2.0", "id": 2, "error": { "code": -32603, "message": "No agent for provider" } } +``` + +### Client-dispatched actions + +Actions are sent as notifications (fire-and-forget, write-ahead): + +``` +Client → Server: { "jsonrpc": "2.0", "method": "dispatchAction", "params": { clientSeq, action } } +``` + +### Reconnection + +`reconnect` is a JSON-RPC **request**. The server MUST include all replayed data in the response: + +``` +Client → Server: { "jsonrpc": "2.0", "id": 2, "method": "reconnect", "params": { clientId, lastSeenServerSeq, subscriptions } } +``` + +If the gap is within the replay buffer, the response contains missed action envelopes: +``` +Server → Client: { "jsonrpc": "2.0", "id": 2, "result": { "type": "replay", "actions": [...] } } +``` + +If the gap exceeds the buffer, the response contains fresh snapshots: +``` +Server → Client: { "jsonrpc": "2.0", "id": 2, "result": { "type": "snapshot", "snapshots": [...] } } +``` + +Protocol notifications are **not** replayed — the client should re-fetch the session list. + +## Write-ahead reconciliation + +### Client-side state + +Each client maintains per-subscription: +- `confirmedState` — last fully server-acknowledged state +- `pendingActions[]` — optimistically applied but not yet echoed by server +- `optimisticState` — `confirmedState` with `pendingActions` replayed on top (computed, not stored) + +### Reconciliation algorithm + +When the client receives an `ActionEnvelope` from the server: + +1. **Own action echoed**: `origin.clientId === myId` and matches head of `pendingActions` → pop from pending, apply to `confirmedState` +2. **Foreign action**: different origin → apply to `confirmedState`, rebase remaining `pendingActions` +3. **Rejected action**: server echoed with `rejectionReason` present → remove from pending (optimistic effect reverted). The `rejectionReason` MAY be surfaced to the user. +4. Recompute `optimisticState` from `confirmedState` + remaining `pendingActions` + +### Why rebasing is simple + +Most session actions are **append-only** (add turn, append delta, add tool call). Pending actions still apply cleanly to an updated confirmed state because they operate on independent data (the turn the client created still exists; the content it appended is additive). The rare true conflict (two clients abort the same turn) is resolved by server-wins semantics. + +## Versioning + +### Protocol version + +Two constants define the version window: +- `PROTOCOL_VERSION` — the current version that new code speaks. +- `MIN_PROTOCOL_VERSION` — the oldest version we maintain compatibility with. + +Bump `PROTOCOL_VERSION` when: +- A new feature area requires capability negotiation (e.g., client must know server supports it before sending commands) +- Behavioral semantics of existing actions change + +Adding **optional** fields to existing action/state types does NOT require a bump. Adding **required** fields or removing/renaming fields **is a compile error** (see below). + +``` +Version history: + 1 — Initial: core session lifecycle, streaming, tools, permissions +``` + +### Version type snapshots + +Each protocol version has a type file (`versions/v1.ts`, `versions/v2.ts`, etc.) that captures the wire format shape of every state type and action type in that version. + +The **latest** version file is the editable "tip" — it can be modified alongside the living types in `sessionState.ts` / `sessionActions.ts`. The compiler enforces that all changes are backwards-compatible. When `PROTOCOL_VERSION` is bumped, the previous version file becomes truly frozen and a new tip is created. + +The version registry (`versions/versionRegistry.ts`) performs **bidirectional assignability checks** between the version types and the living types: + +```typescript +// AssertCompatible requires BOTH directions: +// Current extends Frozen → can't remove fields or change field types +// Frozen extends Current → can't add required fields +// The only allowed evolution is adding optional fields. +type AssertCompatible = Frozen extends Current ? true : never; + +type _check = AssertCompatible; +``` + +| Change to living type | Also update tip? | Compile result | +|---|---|---| +| Add optional field | Yes, add it to tip too | ✅ Passes | +| Add optional field | No, only in living type | ✅ Passes (tip is a subset) | +| Remove a field | — | ❌ `Current extends Frozen` fails | +| Change a field's type | — | ❌ `Current extends Frozen` fails | +| Add required field | — | ❌ `Frozen extends Current` fails | + +### Exhaustive action→version map + +The registry also maintains an exhaustive runtime map: + +```typescript +export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { + 'root/agentsChanged': 1, + 'session/turnStarted': 1, + // ...every action type must have an entry +}; +``` + +The index signature `[K in IStateAction['type']]` means adding a new action to the `IStateAction` union without adding it to this map is a compile error. The developer is forced to pick a version number. + +The server uses this for one-line filtering — no if/else chains: + +```typescript +function isActionKnownToVersion(action: IStateAction, clientVersion: number): boolean { + return ACTION_INTRODUCED_IN[action.type] <= clientVersion; +} +``` + +### Capabilities + +The protocol version maps to a `ProtocolCapabilities` interface for higher-level feature gating: + +```typescript +interface ProtocolCapabilities { + // v1 — always present + readonly sessions: true; + readonly tools: true; + readonly permissions: true; + // v2+ + readonly reasoning?: true; +} +``` + +### Forward compatibility + +A newer client connecting to an older server: +1. During handshake, the client learns the server's protocol version from the `initialize` response. +2. The client derives `ProtocolCapabilities` from the server version. +3. Command factories check capabilities before dispatching; if unsupported, the client degrades gracefully. +4. The server only sends action types known to the client's declared version (via `isActionKnownToVersion`). +5. As a safety net, clients silently ignore actions with unrecognized `type` values. + +### Raising the minimum version + +When `MIN_PROTOCOL_VERSION` is raised from N to N+1: +1. Delete `versions/vN.ts`. +2. Remove the vN compatibility checks from `versions/versionRegistry.ts`. +3. The compiler surfaces any dead code that only existed for vN compatibility. +4. Clean up that dead code. + +### Backward compatibility + +We do not guarantee backward compatibility (older clients connecting to newer servers). Clients should update before the server. + +### Adding a new protocol version (cookbook) + +1. Bump `PROTOCOL_VERSION` in `versions/versionRegistry.ts`. +2. Create `versions/v{N}.ts` — freeze the current types (copy from v{N-1} and add your new types). +3. Add your new action types to the living union in `sessionActions.ts`. +4. Add entries to `ACTION_INTRODUCED_IN` with version N (compiler forces this). +5. Add `AssertCompatible` checks for the new types in `versionRegistry.ts`. +6. Add reducer cases for the new actions (in new functions if desired). +7. Add capability fields to `ProtocolCapabilities` if needed. + +## Reducers + +State is mutated by pure reducer functions that take `(state, action) → newState`. The same reducer code runs on both server and client, which is what makes write-ahead possible: the client can locally predict the result of its own action using the same logic the server will run. + +``` +rootReducer(state: RootState, action: RootAction): RootState +sessionReducer(state: SessionState, action: SessionAction): SessionState +``` + +Reducers are pure (no side effects, no I/O). Server-side effects (e.g. forwarding a `sendMessage` command to the Copilot SDK) are handled by a separate dispatch layer, not in the reducer. + +## File layout + +``` +src/vs/platform/agent/common/state/ +├── sessionState.ts # Immutable state types (RootState, SessionState, Turn, etc.) +├── sessionActions.ts # Action + notification discriminated unions, ActionEnvelope +├── sessionReducers.ts # Pure reducer functions (rootReducer, sessionReducer) +├── sessionProtocol.ts # JSON-RPC message types, request params/results, type guards +├── sessionCapabilities.ts # Re-exports version constants + ProtocolCapabilities +├── sessionClientState.ts # Client-side state manager (confirmed + pending + reconciliation) +└── versions/ + ├── v1.ts # v1 wire format types (tip — editable, compiler-enforced compat) + └── versionRegistry.ts # Compile-time compat checks + runtime action→version map +``` + +## Relationship to existing IPC contract + +The existing `IAgentProgressEvent` union in `agentService.ts` captures raw streaming events from the Copilot SDK. The new action types in `sessionActions.ts` are a higher-level abstraction: they represent state transitions rather than SDK events. + +In the server process, the mapping is: +- `IAgentDeltaEvent` → `session/delta` action +- `IAgentToolStartEvent` → `session/toolStart` action +- `IAgentIdleEvent` → `session/turnComplete` action +- etc. + +The existing `IAgentService` RPC interface remains unchanged. The new protocol layer sits on top: the sessions process uses `IAgentService` internally to talk to agent backends, and produces actions for connected clients. diff --git a/src/vs/platform/agentHost/sessions.md b/src/vs/platform/agentHost/sessions.md new file mode 100644 index 0000000000000..b200662497af6 --- /dev/null +++ b/src/vs/platform/agentHost/sessions.md @@ -0,0 +1,62 @@ +## Chat sessions / background agent architecture + +> **Keep this document in sync with the code.** If you change how session types are registered, modify the extension point, or update the agent-host's registration pattern, update this document as part of the same change. + +There are **three layers** that connect to form a chat session type (like "Background Agent" / "Copilot CLI"): + +### Layer 1: `chatSessions` Extension Point (package.json) + +In package.json, the extension contributes to the `"chatSessions"` extension point. Each entry declares a session **type** (used as a URI scheme), a **name** (used as a chat participant name like `@cli`), display metadata, capabilities, slash commands, and a `when` clause for conditional availability. + +### Layer 2: VS Code Platform -- Extension Point + Service + +On the VS Code side: + +- chatSessions.contribution.ts -- Registers the `chatSessions` extension point via `ExtensionsRegistry.registerExtensionPoint`. When extensions contribute to it, the `ChatSessionsService` processes each contribution: it sets up context keys, icons, welcome messages, commands, and -- if `canDelegate` is true -- also **registers a dynamic chat agent**. + +- chatSessionsService.ts -- The `IChatSessionsService` interface manages two kinds of providers: + - **`IChatSessionItemController`** -- Lists available sessions + - **`IChatSessionContentProvider`** -- Provides session content (history + request handler) when you open a specific session + +- agentSessions.ts -- The `AgentSessionProviders` enum maps well-known types to their string identifiers: + - `Local` = `'local'` + - `Background` = `'copilotcli'` + - `Cloud` = `'copilot-cloud-agent'` + - `Claude` = `'claude-code'` + - `Codex` = `'openai-codex'` + - `Growth` = `'copilot-growth'` + - `AgentHostCopilot` = `'agent-host-copilot'` + +### Layer 3: Extension Side Registration + +Each session type registers three things via the proposed API: + +1. **`vscode.chat.registerChatSessionItemProvider(type, provider)`** -- Provides the list of sessions +2. **`vscode.chat.createChatParticipant(type, handler)`** -- Creates the chat participant +3. **`vscode.chat.registerChatSessionContentProvider(type, contentProvider, chatParticipant)`** -- Binds content provider to participant + +### Agent Host: Internal (Non-Extension) Registration + +The agent-host session types (`agent-host-copilot`) bypass the extension point entirely. A single `AgentHostContribution` discovers available agents from the agent host process via `listAgents()` and dynamically registers each one: + +**For each `IAgentDescriptor` returned by `listAgents()`:** +1. Chat session contribution via `IChatSessionsService.registerChatSessionContribution()` +2. Session item controller via `IChatSessionsService.registerChatSessionItemController()` +3. Session content provider via `IChatSessionsService.registerChatSessionContentProvider()` +4. Language model provider via `ILanguageModelsService.registerLanguageModelProvider()` +5. Auth token push (only if `descriptor.requiresAuth` is true) + +All use the same generic `AgentHostSessionHandler` class, configured with the descriptor's metadata. + +### All Entry Points + +| # | Entry Point | File | +|---|-------------|------| +| 1 | **package.json `chatSessions` contribution** | package.json -- declares type, name, capabilities | +| 2 | **Extension point handler** | chatSessions.contribution.ts -- processes contributions | +| 3 | **Service interface** | chatSessionsService.ts -- `IChatSessionsService` | +| 4 | **Proposed API** | vscode.proposed.chatSessionsProvider.d.ts | +| 5 | **Agent session provider enum** | agentSessions.ts -- `AgentSessionProviders` | +| 6 | **Agent Host contribution** | agentHost/agentHostChatContribution.ts -- `AgentHostContribution` (discovers + registers dynamically) | +| 7 | **Agent Host process** | src/vs/platform/agent/ -- utility process, SDK integration | +| 8 | **Desktop registration** | electron-browser/chat.contribution.ts -- registers `AgentHostContribution` | diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md new file mode 100644 index 0000000000000..4533c3b4e5928 --- /dev/null +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -0,0 +1,454 @@ +# Auth Rework: Standards-Based Authentication for the Agent Host Protocol + +## Problem + +The current authentication mechanism is imperative and VS Code-specific: + +1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. +2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. +3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. +4. The agent host fans the token out to all registered `IAgent` providers. + +This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." + +## Design Goals + +- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. +- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. +- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. +- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. +- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. +- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. + +## Relevant Standards + +### RFC 6750 — Bearer Token Usage + +Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: + +``` +WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" +``` + +Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. + +### RFC 9728 — OAuth 2.0 Protected Resource Metadata + +Defines a metadata document that a protected resource publishes to describe itself: + +```json +{ + "resource": "https://resource.example.com", + "authorization_servers": ["https://as.example.com"], + "scopes_supported": ["profile", "email"], + "bearer_methods_supported": ["header"] +} +``` + +Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. + +## Proposed Design + +### Overview + +The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ Agent Host │ │ Authorization │ +│ │ │ (Server) │ │ Server │ +└────┬─────┘ └──────┬───────┘ └────────┬────────┘ + │ │ │ + │ 1. initialize │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 2. initialize result │ │ + │ { auth: [{ scheme, resource, │ │ + │ authorization_servers, │ │ + │ scopes_supported }] } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 3. Obtain token from AS │ │ + │ ─────────────────────────────────────────────────────────────────> │ + │ │ │ + │ 4. Token │ │ + │ <───────────────────────────────────────────────────────────────── │ + │ │ │ + │ 5. authenticate { scheme, token } │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 6. { authenticated: true } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 7. createSession / other commands │ │ + │ ───────────────────────────────────> │ │ +``` + +### Phase 1: Discovery (in `initialize` response) + +The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: + +```typescript +interface IInitializeResult { + protocolVersion: number; + serverSeq: number; + snapshots: ISnapshot[]; + defaultDirectory?: URI; + + /** RFC 9728-style resource metadata describing auth requirements. */ + resourceMetadata?: IResourceMetadata; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ +interface IResourceMetadata { + /** + * Identifier for this resource (the agent host). + * Analogous to RFC 9728 `resource`. + */ + resource: string; + + /** + * Independent auth requirements. Each entry describes one + * authentication scheme the server accepts. A client must + * satisfy at least one to use authenticated features. + */ + authSchemes: IAuthScheme[]; +} + +/** + * A single authentication scheme the server accepts. + */ +interface IAuthScheme { + /** + * The auth scheme name. Initially only "bearer" (RFC 6750). + * Future schemes (e.g. "dpop", "device_code") can be added. + */ + scheme: 'bearer'; + + /** + * An opaque identifier for this auth requirement, used to + * correlate `authenticate` calls and challenges. Allows the + * server to require multiple independent tokens (e.g. one + * per agent provider). + * + * Example: "github" for GitHub Copilot auth. + */ + id: string; + + /** + * Human-readable label for display in auth UIs. + * Analogous to RFC 9728 `resource_name`. + */ + label: string; + + /** + * Authorization server issuer identifiers (RFC 8414). + * Tells the client where to obtain tokens. + * Analogous to RFC 9728 `authorization_servers`. + * + * Example: ["https://github.com/login/oauth"] + */ + authorizationServers: string[]; + + /** + * OAuth scopes the server needs. + * Analogous to RFC 9728 `scopes_supported`. + * + * Example: ["read:user", "user:email", "repo", "workflow"] + */ + scopesSupported?: string[]; + + /** + * Whether this auth requirement is mandatory for any + * functionality, or only for specific agents/features. + */ + required?: boolean; +} +``` + +**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. + +### Phase 2: Token Delivery (`authenticate` command) + +Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: + +```typescript +/** + * Client → Server request to authenticate. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). + */ +interface IAuthenticateParams { + /** + * The auth scheme identifier from the server's resourceMetadata. + * Correlates to IAuthScheme.id. + */ + schemeId: string; + + /** The scheme type (initially always "bearer"). */ + scheme: 'bearer'; + + /** The bearer token value (RFC 6750). */ + token: string; +} + +interface IAuthenticateResult { + /** Whether the token was accepted. */ + authenticated: boolean; +} +``` + +This is a **request** (not a notification) so: +- The client knows immediately if the token was accepted or rejected. +- The server can validate the token before returning success. +- Errors use structured challenges (see Phase 3). + +The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. + +### Phase 3: Challenges on Failure + +When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: + +```typescript +/** + * JSON-RPC error data for authentication failures. + * Modeled on RFC 6750 WWW-Authenticate challenge parameters. + */ +interface IAuthChallenge { + /** The scheme ID that needs (re-)authentication. */ + schemeId: string; + + /** RFC 6750 §3.1 error code. */ + error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; + + /** Human-readable error description (RFC 6750 §3 error_description). */ + errorDescription?: string; + + /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ + scope?: string; +} +``` + +This is returned as the `data` payload of a JSON-RPC error response: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32007, + "message": "Authentication required", + "data": { + "challenges": [ + { + "schemeId": "github", + "error": "invalid_token", + "errorDescription": "The access token expired" + } + ] + } + } +} +``` + +A dedicated error code (`-32007 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. + +### Phase 4: Auth State Notifications + +The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: + +```typescript +/** + * Server → Client notification when auth state changes. + */ +interface IAuthStateNotification { + type: 'notify/authRequired'; + + /** The scheme ID whose auth state changed. */ + schemeId: string; + + /** The new state. */ + state: 'authenticated' | 'expired' | 'revoked' | 'required'; + + /** Optional challenge with details (e.g. new scopes needed). */ + challenge?: IAuthChallenge; +} +``` + +This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. + +## Concrete Example: GitHub Copilot Auth + +### Server-side (CopilotAgent) + +When the Copilot agent registers, it publishes an auth scheme: + +```typescript +// In CopilotAgent.getAuthSchemes(): +[{ + scheme: 'bearer', + id: 'github', + label: 'GitHub', + authorizationServers: ['https://github.com/login/oauth'], + scopesSupported: ['read:user', 'user:email'], + required: true, +}] +``` + +The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. + +### Client-side (VS Code renderer) + +```typescript +// After initialize: +const metadata = initResult.resourceMetadata; +if (metadata) { + for (const scheme of metadata.authSchemes) { + if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( + as => as.includes('github.com') + )) { + // We know how to handle GitHub auth + const token = await this._getGitHubToken(scheme.scopesSupported); + await agentHostService.authenticate({ + schemeId: scheme.id, + scheme: 'bearer', + token, + }); + } + } +} +``` + +### Client-side (generic external client) + +A CLI tool connecting over WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:3000'); +const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); + +for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { + if (scheme.scheme === 'bearer') { + console.log(`Auth required: ${scheme.label}`); + console.log(`Get a token from: ${scheme.authorizationServers[0]}`); + console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); + + // Client can use any OAuth library to get the token + const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); + await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); + } +} +``` + +## Protocol Changes Summary + +### New JSON-RPC request: `authenticate` + +| Direction | Type | Params | Result | +|---|---|---|---| +| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | + +### New JSON-RPC error code + +| Code | Name | When | +|---|---|---| +| `-32007` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | + +### Extended: `initialize` result + +| Field | Type | Description | +|---|---|---| +| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | + +### New notification + +| Type | Direction | When | +|---|---|---| +| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | + +### Deprecated + +| Item | Replacement | Migration | +|---|---|---| +| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | +| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | + +## Interface Changes in `agentService.ts` + +### `IAgentService` + +```diff + interface IAgentService { +- setAuthToken(token: string): Promise; ++ authenticate(params: IAuthenticateParams): Promise; + } +``` + +### `IAgent` + +```diff + interface IAgent { +- setAuthToken(token: string): Promise; ++ /** Declare auth schemes this agent requires. */ ++ getAuthSchemes(): IAuthScheme[]; ++ /** Authenticate with a specific scheme. Returns true if accepted. */ ++ authenticate(schemeId: string, token: string): Promise; + } +``` + +### `IAgentDescriptor` + +```diff + interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; +- readonly requiresAuth: boolean; + } +``` + +`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. + +## Design Decisions + +### Why not `WWW-Authenticate` headers literally? + +We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. + +### Why in `initialize` and not a separate `getResourceMetadata` command? + +Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. + +### Why `schemeId` and not just the `scheme` name? + +A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. + +### Why a request instead of a notification for `authenticate`? + +The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). + +### What about Device Code / OAuth flows that the server drives? + +This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. + +A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. + +## Migration Plan + +1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. +2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. +3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. + +## Open Questions + +1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. + +2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. + +3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). + +4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts new file mode 100644 index 0000000000000..43c47cb242320 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { agentHostRemotePath, agentHostUri } from '../../common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../common/agentHostUri.js'; + +suite('AgentHostFileSystemProvider - URI helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('agentHostUri builds correct URI', () => { + const uri = agentHostUri('localhost', '/home/user/project'); + assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME); + assert.strictEqual(uri.authority, 'localhost'); + // path encodes file scheme: /file//home/user/project + assert.ok(uri.path.includes('/home/user/project')); + }); + + test('agentHostRemotePath extracts the original path', () => { + const uri = agentHostUri('host', '/some/path'); + assert.strictEqual(agentHostRemotePath(uri), '/some/path'); + }); + + test('agentHostRemotePath round-trips with agentHostUri', () => { + const original = '/home/user/project'; + const uri = agentHostUri('host', original); + assert.strictEqual(agentHostRemotePath(uri), original); + }); +}); + +suite('AgentHostAuthority - encoding', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('purely alphanumeric address is returned as-is', () => { + assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); + }); + + test('normal host:port address uses human-readable encoding', () => { + assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081'); + assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080'); + assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090'); + assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); + }); + + test('address with underscore falls through to base64', () => { + const authority = agentHostAuthority('host_name:8080'); + assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + }); + + test('address with exotic characters is base64-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); + }); + + test('ws:// prefix is normalized so authority matches bare address', () => { + assert.strictEqual(agentHostAuthority('ws://127.0.0.1:8080'), agentHostAuthority('127.0.0.1:8080')); + assert.strictEqual(agentHostAuthority('ws://localhost:9090'), agentHostAuthority('localhost:9090')); + }); + + test('different addresses produce different authorities', () => { + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; + const results = cases.map(agentHostAuthority); + const unique = new Set(results); + assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); + }); + + test('authority is valid in a URI authority position', () => { + const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090']; + for (const address of addresses) { + const authority = agentHostAuthority(address); + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' }); + assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`); + } + }); + + test('authority is valid in a URI scheme position', () => { + const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces']; + for (const address of addresses) { + const authority = agentHostAuthority(address); + const scheme = `remote-${authority}-copilot`; + const uri = URI.from({ scheme, path: '/test' }); + assert.strictEqual(uri.scheme, scheme, `scheme for '${address}' must round-trip through URI`); + } + }); +}); + +suite('toAgentHostUri / fromAgentHostUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a file URI', () => { + const original = URI.file('/home/user/project/file.ts'); + const wrapped = toAgentHostUri(original, 'my-server'); + assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME); + assert.strictEqual(wrapped.authority, 'my-server'); + + const unwrapped = fromAgentHostUri(wrapped); + assert.strictEqual(unwrapped.scheme, 'file'); + assert.strictEqual(unwrapped.path, original.path); + }); + + test('round-trips a URI with authority', () => { + const original = URI.from({ scheme: 'agenthost-content', authority: 'session1', path: '/snap/before' }); + const wrapped = toAgentHostUri(original, 'remote-host'); + const unwrapped = fromAgentHostUri(wrapped); + assert.strictEqual(unwrapped.scheme, 'agenthost-content'); + assert.strictEqual(unwrapped.authority, 'session1'); + assert.strictEqual(unwrapped.path, '/snap/before'); + }); + + test('local authority returns original URI unchanged', () => { + const original = URI.file('/workspace/test.ts'); + const result = toAgentHostUri(original, 'local'); + assert.strictEqual(result.toString(), original.toString()); + }); + + test('agentHostUri for root path produces valid encoded URI', () => { + const authority = agentHostAuthority('localhost:8089'); + const uri = agentHostUri(authority, '/'); + assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME); + assert.strictEqual(uri.authority, authority); + // The decoded path should be root + assert.strictEqual(fromAgentHostUri(uri).path, '/'); + }); + + test('fromAgentHostUri handles malformed path gracefully', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'host', path: '/file' }); + const result = fromAgentHostUri(uri); + // Should not throw - falls back to extracting scheme only + assert.strictEqual(result.scheme, 'file'); + }); +}); + +suite('AGENT_HOST_LABEL_FORMATTER', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Replicates the stripPathSegments logic from the label service to + * verify that the formatter's configuration is consistent with the + * URI encoding. + */ + function stripPath(path: string, segments: number): string { + let pos = 0; + for (let i = 0; i < segments; i++) { + const next = path.indexOf('/', pos + 1); + if (next === -1) { + break; + } + pos = next; + } + return path.substring(pos); + } + + test('stripPathSegments matches URI encoding for file URIs', () => { + const authority = agentHostAuthority('localhost:8089'); + const originalPath = '/Users/roblou/code/vscode'; + const encodedUri = agentHostUri(authority, originalPath); + + const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!); + assert.strictEqual(stripped, originalPath); + }); + + test('stripPathSegments matches URI encoding with authority', () => { + const originalUri = URI.from({ scheme: 'agenthost-content', authority: 'myhost', path: '/snap/before' }); + const encodedUri = toAgentHostUri(originalUri, 'remote-host'); + + const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!); + assert.strictEqual(stripped, '/snap/before'); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts new file mode 100644 index 0000000000000..95cc3c8e7877a --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agentService.js'; + +suite('AgentSession namespace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uri creates a URI with provider as scheme and id as path', () => { + const session = AgentSession.uri('copilot', 'abc-123'); + assert.strictEqual(session.scheme, 'copilot'); + assert.strictEqual(session.path, '/abc-123'); + }); + + test('id extracts the raw session ID from a session URI', () => { + const session = URI.from({ scheme: 'copilot', path: '/my-session-42' }); + assert.strictEqual(AgentSession.id(session), 'my-session-42'); + }); + + test('uri and id are inverse operations', () => { + const rawId = 'test-session-xyz'; + const session = AgentSession.uri('copilot', rawId); + assert.strictEqual(AgentSession.id(session), rawId); + }); + + test('provider extracts copilot from a copilot-scheme URI', () => { + const session = AgentSession.uri('copilot', 'sess-1'); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts new file mode 100644 index 0000000000000..34aab19e92f08 --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -0,0 +1,428 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { RemoteAgentHostService } from '../../electron-browser/remoteAgentHostServiceImpl.js'; +import { parseRemoteAgentHostInput, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; + +// ---- Mock protocol client --------------------------------------------------- + +class MockProtocolClient extends Disposable { + private static _nextId = 1; + readonly clientId = `mock-client-${MockProtocolClient._nextId++}`; + + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + readonly onDidAction = Event.None; + readonly onDidNotification = Event.None; + + public connectDeferred = new DeferredPromise(); + + constructor(public readonly mockAddress: string) { + super(); + } + + async connect(): Promise { + return this.connectDeferred.p; + } + + fireClose(): void { + this._onDidClose.fire(); + } +} + +// ---- Test configuration service --------------------------------------------- + +class TestConfigurationService { + private readonly _onDidChangeConfiguration = new Emitter>(); + readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; + + private _entries: IRemoteAgentHostEntry[] = []; + private _enabled = true; + + getValue(key?: string): unknown { + if (key === RemoteAgentHostsEnabledSettingId) { + return this._enabled; + } + return this._entries; + } + + inspect(_key: string) { + return { + userValue: this._entries, + }; + } + + async updateValue(_key: string, value: unknown): Promise { + this.setEntries((value as IRemoteAgentHostEntry[] | undefined) ?? []); + } + + get entries(): readonly IRemoteAgentHostEntry[] { + return this._entries; + } + + setEntries(entries: IRemoteAgentHostEntry[]): void { + this._entries = entries; + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId, + }); + } + + setEnabled(enabled: boolean): void { + this._enabled = enabled; + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (key: string) => key === RemoteAgentHostsEnabledSettingId, + }); + } + + dispose(): void { + this._onDidChangeConfiguration.dispose(); + } +} + +suite('RemoteAgentHostService', () => { + + const disposables = new DisposableStore(); + let configService: TestConfigurationService; + let createdClients: MockProtocolClient[]; + let service: RemoteAgentHostService; + + setup(() => { + configService = new TestConfigurationService(); + disposables.add(toDisposable(() => configService.dispose())); + + createdClients = []; + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IConfigurationService, configService as Partial); + + // Mock the instantiation service to capture created protocol clients + const mockInstantiationService: Partial = { + createInstance: (_ctor: unknown, ...args: unknown[]) => { + const client = new MockProtocolClient(args[0] as string); + disposables.add(client); + createdClients.push(client); + return client; + }, + }; + instantiationService.stub(IInstantiationService, mockInstantiationService as Partial); + + service = disposables.add(instantiationService.createInstance(RemoteAgentHostService)); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('starts with no connections when setting is empty', () => { + assert.deepStrictEqual(service.connections, []); + }); + + test('parses supported remote host inputs', () => { + assert.deepStrictEqual([ + parseRemoteAgentHostInput('Listening on ws://127.0.0.1:8089'), + parseRemoteAgentHostInput('Agent host proxy listening on ws://127.0.0.1:8089'), + parseRemoteAgentHostInput('127.0.0.1:8089'), + parseRemoteAgentHostInput('ws://127.0.0.1:8089'), + parseRemoteAgentHostInput('ws://127.0.0.1:40147?tkn=c9d12867-da33-425e-8d39-0d071e851597'), + parseRemoteAgentHostInput('wss://secure.example.com:443'), + ], [ + { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, + { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, + { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, + { parsed: { address: '127.0.0.1:8089', connectionToken: undefined, suggestedName: '127.0.0.1:8089' } }, + { parsed: { address: '127.0.0.1:40147', connectionToken: 'c9d12867-da33-425e-8d39-0d071e851597', suggestedName: '127.0.0.1:40147' } }, + { parsed: { address: 'wss://secure.example.com', connectionToken: undefined, suggestedName: 'secure.example.com' } }, + ]); + }); + + test('getConnection returns undefined for unknown address', () => { + assert.strictEqual(service.getConnection('ws://unknown:1234'), undefined); + }); + + test('creates connection when setting is updated', async () => { + const connectionChanged = Event.toPromise(service.onDidChangeConnections); + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + + // Resolve the connect promise + assert.strictEqual(createdClients.length, 1); + createdClients[0].connectDeferred.complete(); + await connectionChanged; + + assert.strictEqual(service.connections.length, 1); + assert.strictEqual(service.connections[0].address, 'host1:8080'); + assert.strictEqual(service.connections[0].name, 'Host 1'); + }); + + test('getConnection returns client after successful connect', async () => { + const connectionChanged = Event.toPromise(service.onDidChangeConnections); + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await connectionChanged; + + const connection = service.getConnection('ws://host1:8080'); + assert.ok(connection); + assert.strictEqual(connection.clientId, createdClients[0].clientId); + }); + + test('removes connection when setting entry is removed', async () => { + // Add a connection + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + // Remove it + const removedEvent = Event.toPromise(service.onDidChangeConnections); + configService.setEntries([]); + await removedEvent; + + assert.strictEqual(service.connections.length, 0); + assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); + }); + + test('fires onDidChangeConnections when connection closes', async () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + // Simulate connection close + const closedEvent = Event.toPromise(service.onDidChangeConnections); + createdClients[0].fireClose(); + await closedEvent; + + assert.strictEqual(service.connections.length, 0); + assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); + }); + + test('removes connection on connect failure', async () => { + configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]); + assert.strictEqual(createdClients.length, 1); + + // Fail the connection + createdClients[0].connectDeferred.error(new Error('Connection refused')); + + // Wait for async error handling + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(service.connections.length, 0); + assert.strictEqual(service.getConnection('ws://bad:9999'), undefined); + }); + + test('manages multiple connections independently', async () => { + configService.setEntries([ + { address: 'ws://host1:8080', name: 'Host 1' }, + { address: 'ws://host2:8080', name: 'Host 2' }, + ]); + + assert.strictEqual(createdClients.length, 2); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + createdClients[1].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + assert.strictEqual(service.connections.length, 2); + + const conn1 = service.getConnection('ws://host1:8080'); + const conn2 = service.getConnection('ws://host2:8080'); + assert.ok(conn1); + assert.ok(conn2); + assert.notStrictEqual(conn1.clientId, conn2.clientId); + }); + + test('does not re-create existing connections on setting update', async () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + const firstClientId = createdClients[0].clientId; + + // Update setting with same address (but different name) + configService.setEntries([{ address: 'ws://host1:8080', name: 'Renamed' }]); + + // Should NOT have created a second client + assert.strictEqual(createdClients.length, 1); + + // Connection should still work with same client + const conn = service.getConnection('ws://host1:8080'); + assert.ok(conn); + assert.strictEqual(conn.clientId, firstClientId); + + // But name should be updated + assert.strictEqual(service.connections[0].name, 'Renamed'); + }); + + test('addRemoteAgentHost stores the entry and waits for connection', async () => { + const connectionPromise = service.addRemoteAgentHost({ + address: 'ws://host1:8080', + name: 'Host 1', + connectionToken: 'secret-token', + }); + + assert.deepStrictEqual(configService.entries, [{ + address: 'host1:8080', + name: 'Host 1', + connectionToken: 'secret-token', + }]); + assert.strictEqual(createdClients.length, 1); + + createdClients[0].connectDeferred.complete(); + const connection = await connectionPromise; + + assert.deepStrictEqual(connection, { + address: 'host1:8080', + name: 'Host 1', + clientId: createdClients[0].clientId, + defaultDirectory: undefined, + }); + }); + + test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + const connection = await service.addRemoteAgentHost({ + address: 'ws://host1:8080', + name: 'Updated Host', + connectionToken: 'new-token', + }); + + assert.strictEqual(createdClients.length, 1); + assert.deepStrictEqual(configService.entries, [{ + address: 'host1:8080', + name: 'Updated Host', + connectionToken: 'new-token', + }]); + assert.deepStrictEqual(connection, { + address: 'host1:8080', + name: 'Updated Host', + clientId: createdClients[0].clientId, + defaultDirectory: undefined, + }); + }); + + test('addRemoteAgentHost appends when adding a second host', async () => { + // Add first host + const firstPromise = service.addRemoteAgentHost({ + address: 'host1:8080', + name: 'Host 1', + }); + createdClients[0].connectDeferred.complete(); + await firstPromise; + + // Add second host + const secondPromise = service.addRemoteAgentHost({ + address: 'host2:9090', + name: 'Host 2', + }); + createdClients[1].connectDeferred.complete(); + await secondPromise; + + assert.strictEqual(createdClients.length, 2); + assert.deepStrictEqual(configService.entries, [ + { address: 'host1:8080', name: 'Host 1' }, + { address: 'host2:9090', name: 'Host 2' }, + ]); + assert.strictEqual(service.connections.length, 2); + }); + + test('addRemoteAgentHost resolves when connection completes before wait is created', async () => { + // Simulate a fast connect: the mock client resolves synchronously + // during the config change handler, before addRemoteAgentHost has a + // chance to create its DeferredPromise wait. + const originalSetEntries = configService.setEntries.bind(configService); + configService.setEntries = (entries: IRemoteAgentHostEntry[]) => { + originalSetEntries(entries); + // Complete the connection synchronously inside the config change callback + if (createdClients.length > 0) { + createdClients[createdClients.length - 1].connectDeferred.complete(); + } + }; + + const connection = await service.addRemoteAgentHost({ + address: 'fast-host:1234', + name: 'Fast Host', + }); + + assert.strictEqual(connection.address, 'fast-host:1234'); + assert.strictEqual(connection.name, 'Fast Host'); + }); + + test('disabling the enabled setting disconnects all remotes', async () => { + configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + assert.strictEqual(service.connections.length, 1); + + configService.setEnabled(false); + + assert.strictEqual(service.connections.length, 0); + }); + + test('addRemoteAgentHost throws when disabled', async () => { + configService.setEnabled(false); + + await assert.rejects( + () => service.addRemoteAgentHost({ address: 'host1:8080', name: 'Host 1' }), + /not enabled/, + ); + }); + + test('re-enabling reconnects configured remotes', async () => { + configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + assert.strictEqual(service.connections.length, 1); + + configService.setEnabled(false); + assert.strictEqual(service.connections.length, 0); + + configService.setEnabled(true); + assert.strictEqual(createdClients.length, 2); // new client created + createdClients[1].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + assert.strictEqual(service.connections.length, 1); + }); + + test('removeRemoteAgentHost removes entry and disconnects', async () => { + configService.setEntries([ + { address: 'ws://host1:8080', name: 'Host 1' }, + { address: 'ws://host2:9090', name: 'Host 2' }, + ]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + createdClients[1].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + assert.strictEqual(service.connections.length, 2); + + await service.removeRemoteAgentHost('ws://host1:8080'); + + assert.deepStrictEqual(configService.entries, [ + { address: 'ws://host2:9090', name: 'Host 2' }, + ]); + assert.strictEqual(service.connections.length, 1); + assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); + assert.ok(service.getConnection('ws://host2:9090')); + }); + + test('removeRemoteAgentHost normalizes address before removing', async () => { + configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await Event.toPromise(service.onDidChangeConnections); + + await service.removeRemoteAgentHost('ws://host1:8080'); + + assert.deepStrictEqual(configService.entries, []); + assert.strictEqual(service.connections.length, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts new file mode 100644 index 0000000000000..a415f659573a3 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { + IAgentDeltaEvent, + IAgentErrorEvent, + IAgentIdleEvent, + IAgentMessageEvent, + IAgentReasoningEvent, + IAgentTitleChangedEvent, + IAgentToolCompleteEvent, + IAgentToolStartEvent, + IAgentUsageEvent, +} from '../../common/agentService.js'; +import type { + IDeltaAction, + IReasoningAction, + IResponsePartAction, + ISessionAction, + ISessionErrorAction, + ITitleChangedAction, + IToolCallCompleteAction, + IToolCallReadyAction, + IToolCallStartAction, + ITurnCompleteAction, + IUsageAction, +} from '../../common/state/sessionActions.js'; +import { ToolResultContentType, type IMarkdownResponsePart, type IReasoningResponsePart } from '../../common/state/sessionState.js'; +import { AgentEventMapper } from '../../node/agentEventMapper.js'; + +/** Helper: flatten the result of mapProgressEventToActions into an array. */ +function mapToArray(result: ISessionAction | ISessionAction[] | undefined): ISessionAction[] { + if (!result) { + return []; + } + return Array.isArray(result) ? result : [result]; +} + +suite('AgentEventMapper', () => { + + const session = URI.from({ scheme: 'copilot', path: '/test-session' }); + const turnId = 'turn-1'; + let mapper: AgentEventMapper; + + setup(() => { + mapper = new AgentEventMapper(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('first delta event creates a responsePart with content', () => { + const event: IAgentDeltaEvent = { + session, + type: 'delta', + messageId: 'msg-1', + content: 'hello world', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/responsePart'); + const part = (actions[0] as IResponsePartAction).part; + assert.strictEqual(part.kind, 'markdown'); + assert.strictEqual(part.content, 'hello world'); + assert.ok(part.id); + }); + + test('subsequent delta event maps to session/delta action', () => { + const first: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'hello ' }; + const second: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'world' }; + + const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); + const partId = ((firstActions[0] as IResponsePartAction).part as IMarkdownResponsePart).id; + + const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); + assert.strictEqual(secondActions.length, 1); + const delta = secondActions[0] as IDeltaAction; + assert.strictEqual(delta.type, 'session/delta'); + assert.strictEqual(delta.content, 'world'); + assert.strictEqual(delta.partId, partId); + }); + + test('tool_start event maps to toolCallStart + toolCallReady actions', () => { + const event: IAgentToolStartEvent = { + session, + type: 'tool_start', + toolCallId: 'tc-1', + toolName: 'readFile', + displayName: 'Read File', + invocationMessage: 'Reading file...', + toolInput: '/src/foo.ts', + toolKind: 'terminal', + language: 'shellscript', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 2); + + const startAction = actions[0] as IToolCallStartAction; + assert.strictEqual(startAction.type, 'session/toolCallStart'); + assert.strictEqual(startAction.toolCallId, 'tc-1'); + assert.strictEqual(startAction.toolName, 'readFile'); + assert.strictEqual(startAction.displayName, 'Read File'); + assert.strictEqual(startAction._meta?.toolKind, 'terminal'); + assert.strictEqual(startAction._meta?.language, 'shellscript'); + + const readyAction = actions[1] as IToolCallReadyAction; + assert.strictEqual(readyAction.type, 'session/toolCallReady'); + assert.strictEqual(readyAction.toolCallId, 'tc-1'); + assert.strictEqual(readyAction.invocationMessage, 'Reading file...'); + assert.strictEqual(readyAction.toolInput, '/src/foo.ts'); + assert.strictEqual(readyAction.confirmed, 'not-needed'); + }); + + test('tool_complete event maps to session/toolCallComplete action', () => { + const event: IAgentToolCompleteEvent = { + session, + type: 'tool_complete', + toolCallId: 'tc-1', + result: { + success: true, + pastTenseMessage: 'Read file successfully', + content: [{ type: ToolResultContentType.Text, text: 'file contents here' }], + }, + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + const complete = actions[0] as IToolCallCompleteAction; + assert.strictEqual(complete.type, 'session/toolCallComplete'); + assert.strictEqual(complete.toolCallId, 'tc-1'); + assert.strictEqual(complete.result.success, true); + assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully'); + assert.deepStrictEqual(complete.result.content, [{ type: 'text', text: 'file contents here' }]); + }); + + test('idle event maps to session/turnComplete action', () => { + const event: IAgentIdleEvent = { + session, + type: 'idle', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + const turnComplete = actions[0] as ITurnCompleteAction; + assert.strictEqual(turnComplete.type, 'session/turnComplete'); + assert.strictEqual(turnComplete.session.toString(), session.toString()); + assert.strictEqual(turnComplete.turnId, turnId); + }); + + test('error event maps to session/error action', () => { + const event: IAgentErrorEvent = { + session, + type: 'error', + errorType: 'runtime', + message: 'Something went wrong', + stack: 'Error: Something went wrong\n at foo.ts:1', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + const errorAction = actions[0] as ISessionErrorAction; + assert.strictEqual(errorAction.type, 'session/error'); + assert.strictEqual(errorAction.error.errorType, 'runtime'); + assert.strictEqual(errorAction.error.message, 'Something went wrong'); + assert.strictEqual(errorAction.error.stack, 'Error: Something went wrong\n at foo.ts:1'); + }); + + test('usage event maps to session/usage action', () => { + const event: IAgentUsageEvent = { + session, + type: 'usage', + inputTokens: 100, + outputTokens: 50, + model: 'gpt-4', + cacheReadTokens: 25, + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + const usageAction = actions[0] as IUsageAction; + assert.strictEqual(usageAction.type, 'session/usage'); + assert.strictEqual(usageAction.usage.inputTokens, 100); + assert.strictEqual(usageAction.usage.outputTokens, 50); + assert.strictEqual(usageAction.usage.model, 'gpt-4'); + assert.strictEqual(usageAction.usage.cacheReadTokens, 25); + }); + + test('title_changed event maps to session/titleChanged action', () => { + const event: IAgentTitleChangedEvent = { + session, + type: 'title_changed', + title: 'New Title', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/titleChanged'); + assert.strictEqual((actions[0] as ITitleChangedAction).title, 'New Title'); + }); + + test('first reasoning event creates a responsePart with content', () => { + const event: IAgentReasoningEvent = { + session, + type: 'reasoning', + content: 'Let me think about this...', + }; + + const actions = mapToArray(mapper.mapProgressEventToActions(event, session.toString(), turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/responsePart'); + const part = (actions[0] as IResponsePartAction).part; + assert.strictEqual(part.kind, 'reasoning'); + assert.strictEqual(part.content, 'Let me think about this...'); + assert.ok(part.id); + }); + + test('subsequent reasoning event maps to session/reasoning action', () => { + const first: IAgentReasoningEvent = { session, type: 'reasoning', content: 'Let me think...' }; + const second: IAgentReasoningEvent = { session, type: 'reasoning', content: ' more thoughts' }; + + const firstActions = mapToArray(mapper.mapProgressEventToActions(first, session.toString(), turnId)); + const partId = ((firstActions[0] as IResponsePartAction).part as IReasoningResponsePart).id; + + const secondActions = mapToArray(mapper.mapProgressEventToActions(second, session.toString(), turnId)); + assert.strictEqual(secondActions.length, 1); + const reasoning = secondActions[0] as IReasoningAction; + assert.strictEqual(reasoning.type, 'session/reasoning'); + assert.strictEqual(reasoning.content, ' more thoughts'); + assert.strictEqual(reasoning.partId, partId); + }); + + test('message event returns undefined', () => { + const event: IAgentMessageEvent = { + session, + type: 'message', + role: 'assistant', + messageId: 'msg-1', + content: 'Some full message', + }; + + const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts new file mode 100644 index 0000000000000..724044d8c3a2d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { AgentSession } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; +import { AgentService } from '../../node/agentService.js'; +import { MockAgent } from './mockAgent.js'; + +suite('AgentService (node dispatcher)', () => { + + const disposables = new DisposableStore(); + let service: AgentService; + let copilotAgent: MockAgent; + + setup(() => { + const nullSessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; + service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService())), nullSessionDataService)); + copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Provider registration ------------------------------------------ + + suite('registerProvider', () => { + + test('registers a provider successfully', () => { + service.registerProvider(copilotAgent); + // No throw - success + }); + + test('throws on duplicate provider registration', () => { + service.registerProvider(copilotAgent); + const duplicate = new MockAgent('copilot'); + disposables.add(toDisposable(() => duplicate.dispose())); + assert.throws(() => service.registerProvider(duplicate), /already registered/); + }); + + test('maps progress events to protocol actions via onDidAction', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + + // Start a turn so there's an active turn to map events to + service.dispatchAction( + { type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, + 'test-client', 1, + ); + + const envelopes: IActionEnvelope[] = []; + disposables.add(service.onDidAction(e => envelopes.push(e))); + + copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); + }); + }); + + // ---- listAgents ----------------------------------------------------- + + suite('listAgents', () => { + + test('returns descriptors from all registered providers', async () => { + service.registerProvider(copilotAgent); + + const agents = await service.listAgents(); + assert.strictEqual(agents.length, 1); + assert.ok(agents.some(a => a.provider === 'copilot')); + }); + + test('returns empty array when no providers are registered', async () => { + const agents = await service.listAgents(); + assert.strictEqual(agents.length, 0); + }); + }); + + // ---- createSession -------------------------------------------------- + + suite('createSession', () => { + + test('creates session via specified provider', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); + + test('uses default provider when none specified', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession(); + assert.strictEqual(AgentSession.provider(session), 'copilot'); + }); + + test('throws when no providers are registered at all', async () => { + await assert.rejects(() => service.createSession(), /No agent provider/); + }); + }); + + // ---- disposeSession ------------------------------------------------- + + suite('disposeSession', () => { + + test('dispatches to the correct provider and cleans up tracking', async () => { + service.registerProvider(copilotAgent); + + const session = await service.createSession({ provider: 'copilot' }); + await service.disposeSession(session); + + assert.strictEqual(copilotAgent.disposeSessionCalls.length, 1); + }); + + test('is a no-op for unknown sessions', async () => { + service.registerProvider(copilotAgent); + const unknownSession = URI.from({ scheme: 'unknown', path: '/nope' }); + + // Should not throw + await service.disposeSession(unknownSession); + }); + }); + + // ---- listSessions / listModels -------------------------------------- + + suite('aggregation', () => { + + test('listSessions aggregates sessions from all providers', async () => { + service.registerProvider(copilotAgent); + + await service.createSession({ provider: 'copilot' }); + + const sessions = await service.listSessions(); + assert.strictEqual(sessions.length, 1); + }); + + test('refreshModels publishes models in root state via agentsChanged', async () => { + service.registerProvider(copilotAgent); + + const envelopes: IActionEnvelope[] = []; + disposables.add(service.onDidAction(e => envelopes.push(e))); + + service.refreshModels(); + + // Model fetch is async inside AgentSideEffects — wait for it + await new Promise(r => setTimeout(r, 50)); + + const agentsChanged = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); + assert.ok(agentsChanged); + }); + }); + + // ---- getResourceMetadata -------------------------------------------- + + suite('getResourceMetadata', () => { + + test('aggregates protected resources from all providers', async () => { + service.registerProvider(copilotAgent); + + const mockAgent = new MockAgent('other'); + disposables.add(toDisposable(() => mockAgent.dispose())); + service.registerProvider(mockAgent); + + const metadata = await service.getResourceMetadata(); + // copilot agent returns one resource (https://api.github.com), + // generic MockAgent('other') returns empty + assert.deepStrictEqual(metadata, { + resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }], + }); + }); + + test('returns empty resources when no providers registered', async () => { + const metadata = await service.getResourceMetadata(); + assert.deepStrictEqual(metadata, { resources: [] }); + }); + }); + + // ---- authenticate --------------------------------------------------- + + suite('authenticate', () => { + + test('routes token to provider matching the resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' }); + + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]); + }); + + test('returns not authenticated for unknown resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); + + assert.deepStrictEqual(result, { authenticated: false }); + assert.strictEqual(copilotAgent.authenticateCalls.length, 0); + }); + }); + + // ---- shutdown ------------------------------------------------------- + + suite('shutdown', () => { + + test('shuts down all providers', async () => { + let copilotShutdown = false; + copilotAgent.shutdown = async () => { copilotShutdown = true; }; + + service.registerProvider(copilotAgent); + + await service.shutdown(); + assert.ok(copilotShutdown); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts new file mode 100644 index 0000000000000..5822a802fff04 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -0,0 +1,525 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession, IAgent } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { ResponsePartKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; +import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; +import { MockAgent } from './mockAgent.js'; + +// ---- Tests ------------------------------------------------------------------ + +suite('AgentSideEffects', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let stateManager: SessionStateManager; + let agent: MockAgent; + let sideEffects: AgentSideEffects; + let agentList: ReturnType>; + + const sessionUri = AgentSession.uri('mock', 'session-1'); + + function setupSession(): void { + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); + } + + function startTurn(turnId: string): void { + stateManager.dispatchClientAction( + { type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, + { clientId: 'test', clientSeq: 1 }, + ); + } + + setup(async () => { + fileService = disposables.add(new FileService(new NullLogService())); + const memFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); + + // Seed a file so the handleBrowseDirectory tests can distinguish files from dirs + const testDir = URI.from({ scheme: Schemas.inMemory, path: '/testDir' }); + await fileService.createFolder(testDir); + await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); + + agent = new MockAgent(); + disposables.add(toDisposable(() => agent.dispose())); + stateManager = disposables.add(new SessionStateManager(new NullLogService())); + agentList = observableValue('agents', [agent]); + sideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: { + _serviceBrand: undefined, + getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + } satisfies ISessionDataService, + }, new NullLogService(), fileService)); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- handleAction: session/turnStarted ------------------------------ + + suite('handleAction — session/turnStarted', () => { + + test('calls sendMessage on the agent', async () => { + setupSession(); + const action: ISessionAction = { + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: 'hello world' }, + }; + sideEffects.handleAction(action); + + // sendMessage is async but fire-and-forget; wait a tick + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world' }]); + }); + + test('dispatches session/error when no agent is found', async () => { + setupSession(); + const emptyAgents = observableValue('agents', []); + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: emptyAgents, + sessionDataService: {} as ISessionDataService, + }, new NullLogService(), fileService)); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + noAgentSideEffects.handleAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError); + assert.ok(errorAction, 'should dispatch session/error'); + }); + }); + + // ---- handleAction: session/turnCancelled ---------------------------- + + suite('handleAction — session/turnCancelled', () => { + + test('calls abortSession on the agent', async () => { + setupSession(); + sideEffects.handleAction({ + type: ActionType.SessionTurnCancelled, + session: sessionUri.toString(), + turnId: 'turn-1', + }); + + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.abortSessionCalls, [URI.parse(sessionUri.toString())]); + }); + }); + + // ---- handleAction: session/modelChanged ----------------------------- + + suite('handleAction — session/modelChanged', () => { + + test('calls changeModel on the agent', async () => { + setupSession(); + sideEffects.handleAction({ + type: ActionType.SessionModelChanged, + session: sessionUri.toString(), + model: 'gpt-5', + }); + + await new Promise(r => setTimeout(r, 10)); + + assert.deepStrictEqual(agent.changeModelCalls, [{ session: URI.parse(sessionUri.toString()), model: 'gpt-5' }]); + }); + }); + + // ---- registerProgressListener --------------------------------------- + + suite('registerProgressListener', () => { + + test('maps agent progress events to state actions', () => { + setupSession(); + startTurn('turn-1'); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' }); + + // First delta creates a response part (not a delta action) + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionResponsePart)); + }); + + test('returns a disposable that stops listening', () => { + setupSession(); + startTurn('turn-1'); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + const listener = sideEffects.registerProgressListener(agent); + + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' }); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); + + listener.dispose(); + agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' }); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionResponsePart).length, 1); + }); + }); + + // ---- handleCreateSession -------------------------------------------- + + suite('handleCreateSession', () => { + + test('creates a session and dispatches session/ready', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + await sideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'mock' }); + + const ready = envelopes.find(e => e.action.type === ActionType.SessionReady); + assert.ok(ready, 'should dispatch session/ready'); + }); + + test('throws when no provider is specified', async () => { + await assert.rejects( + () => sideEffects.handleCreateSession({ session: sessionUri.toString() }), + /No provider specified/, + ); + }); + + test('throws when no agent matches provider', async () => { + const emptyAgents = observableValue('agents', []); + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: emptyAgents, + sessionDataService: {} as ISessionDataService, + }, new NullLogService(), fileService)); + + await assert.rejects( + () => noAgentSideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'nonexistent' }), + /No agent registered/, + ); + }); + }); + + // ---- handleDisposeSession ------------------------------------------- + + suite('handleDisposeSession', () => { + + test('disposes the session on the agent and removes state', async () => { + setupSession(); + + sideEffects.handleDisposeSession(sessionUri.toString()); + + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(agent.disposeSessionCalls.length, 1); + assert.strictEqual(stateManager.getSessionState(sessionUri.toString()), undefined); + }); + }); + + // ---- handleListSessions --------------------------------------------- + + suite('handleListSessions', () => { + + test('aggregates sessions from all agents', async () => { + await agent.createSession(); + const sessions = await sideEffects.handleListSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].provider, 'mock'); + assert.strictEqual(sessions[0].title, 'Session'); + }); + }); + + // ---- handleRestoreSession ----------------------------------------------- + + suite('handleRestoreSession', () => { + + test('restores a session with message history into the state manager', async () => { + // Create a session on the agent backend (not in the state manager) + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + // Set up the agent's stored messages + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] }, + ]; + + // Before restore, state manager shouldn't have it + assert.strictEqual(stateManager.getSessionState(sessionResource), undefined); + + await sideEffects.handleRestoreSession(sessionResource); + + // After restore, state manager should have it + const state = stateManager.getSessionState(sessionResource); + assert.ok(state, 'session should be in state manager'); + assert.strictEqual(state!.lifecycle, SessionLifecycle.Ready); + assert.strictEqual(state!.turns.length, 1); + assert.strictEqual(state!.turns[0].userMessage.text, 'Hello'); + const mdPart = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(mdPart, 'should have a markdown response part'); + assert.strictEqual(mdPart.content, 'Hi there!'); + assert.strictEqual(state!.turns[0].state, TurnState.Complete); + }); + + test('restores a session with tool calls', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Run a command', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'I will run a command.', toolRequests: [{ toolCallId: 'tc-1', name: 'shell' }] }, + { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'shell', displayName: 'Shell', invocationMessage: 'Running command...' }, + { type: 'tool_complete', session, toolCallId: 'tc-1', result: { success: true, pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'output' }] } }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Done!', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 1); + + const turn = state!.turns[0]; + const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.strictEqual(toolCallParts.length, 1); + const tc = toolCallParts[0].toolCall as IToolCallCompletedState; + assert.strictEqual(tc.status, ToolCallStatus.Completed); + assert.strictEqual(tc.toolCallId, 'tc-1'); + assert.strictEqual(tc.toolName, 'shell'); + assert.strictEqual(tc.displayName, 'Shell'); + assert.strictEqual(tc.success, true); + assert.strictEqual(tc.confirmed, ToolCallConfirmationReason.NotNeeded); + }); + + test('restores a session with multiple turns', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'First question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'First answer', toolRequests: [] }, + { type: 'message', session, role: 'user', messageId: 'msg-3', content: 'Second question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-4', content: 'Second answer', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 2); + assert.strictEqual(state!.turns[0].userMessage.text, 'First question'); + const mdPart0 = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.strictEqual(mdPart0?.content, 'First answer'); + assert.strictEqual(state!.turns[1].userMessage.text, 'Second question'); + const mdPart1 = state!.turns[1].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.strictEqual(mdPart1?.content, 'Second answer'); + }); + + test('flushes interrupted turns when user message arrives without closing assistant message', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted question', toolRequests: [] }, + // No assistant message - the turn was interrupted + { type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried question', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns.length, 2); + assert.strictEqual(state!.turns[0].userMessage.text, 'Interrupted question'); + const mdPart0 = state!.turns[0].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(!mdPart0 || mdPart0.content === '', 'interrupted turn should have empty response'); + assert.strictEqual(state!.turns[0].state, TurnState.Cancelled); + assert.strictEqual(state!.turns[1].userMessage.text, 'Retried question'); + const mdPart1 = state!.turns[1].responseParts.find((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.strictEqual(mdPart1?.content, 'Answer'); + assert.strictEqual(state!.turns[1].state, TurnState.Complete); + }); + + test('is a no-op for a session already in the state manager', async () => { + setupSession(); + // Should not throw or create a duplicate + await sideEffects.handleRestoreSession(sessionUri.toString()); + assert.ok(stateManager.getSessionState(sessionUri.toString())); + }); + + test('throws when no agent found for session', async () => { + const noAgentSideEffects = disposables.add(new AgentSideEffects(stateManager, { + getAgent: () => undefined, + agents: observableValue('agents', []), + sessionDataService: {} as ISessionDataService, + }, new NullLogService(), fileService)); + + await assert.rejects( + () => noAgentSideEffects.handleRestoreSession('unknown://session-1'), + /No agent for session/, + ); + }); + + test('response parts include markdown segments', async () => { + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'response text', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.turns[0].responseParts.length, 1); + assert.strictEqual(state!.turns[0].responseParts[0].kind, ResponsePartKind.Markdown); + assert.strictEqual(state!.turns[0].responseParts[0].content, 'response text'); + }); + + test('throws when session is not found on backend', async () => { + // Agent exists but session is not in listSessions + await assert.rejects( + () => sideEffects.handleRestoreSession(AgentSession.uri('mock', 'nonexistent').toString()), + /Session not found on backend/, + ); + }); + + test('preserves workingDirectory from agent metadata', async () => { + agent.sessionMetadataOverrides = { workingDirectory: '/home/user/project' }; + const session = await agent.createSession(); + const sessions = await agent.listSessions(); + const sessionResource = sessions[0].session.toString(); + + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'hi', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'hello', toolRequests: [] }, + ]; + + await sideEffects.handleRestoreSession(sessionResource); + + const state = stateManager.getSessionState(sessionResource); + assert.ok(state); + assert.strictEqual(state!.summary.workingDirectory, '/home/user/project'); + }); + }); + + // ---- handleBrowseDirectory ------------------------------------------ + + suite('handleBrowseDirectory', () => { + + test('throws when the directory does not exist', async () => { + await assert.rejects( + () => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' }).toString()), + /Directory not found/, + ); + }); + + test('throws when the target is not a directory', async () => { + await assert.rejects( + () => sideEffects.handleBrowseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }).toString()), + /Not a directory/, + ); + }); + }); + + // ---- agents observable -------------------------------------------------- + + suite('agents observable', () => { + + test('dispatches root/agentsChanged when observable changes', async () => { + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agentList.set([agent], undefined); + + // Model fetch is async — wait for it + await new Promise(r => setTimeout(r, 50)); + + const action = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); + assert.ok(action, 'should dispatch root/agentsChanged'); + }); + }); + + // ---- handleGetResourceMetadata / handleAuthenticate ----------------- + + suite('auth', () => { + + test('handleGetResourceMetadata aggregates resources from agents', () => { + agentList.set([agent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 0, 'mock agent has no protected resources'); + }); + + test('handleGetResourceMetadata returns resources when agent declares them', () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 1); + assert.strictEqual(metadata.resources[0].resource, 'https://api.github.com'); + }); + + test('handleAuthenticate returns authenticated for matching resource', async () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://api.github.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'test-token' }]); + }); + + test('handleAuthenticate returns not authenticated for non-matching resource', async () => { + agentList.set([agent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://unknown.example.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: false }); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts new file mode 100644 index 0000000000000..8679dab91e452 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ToolResultContentType } from '../../common/state/sessionState.js'; +import { SessionDataService } from '../../node/sessionDataService.js'; +import { FileEditTracker } from '../../node/copilot/fileEditTracker.js'; + +suite('FileEditTracker', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let sessionDataService: ISessionDataService; + let tracker: FileEditTracker; + + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + sessionDataService = new SessionDataService(basePath, fileService, new NullLogService()); + tracker = new FileEditTracker('test-session', sessionDataService, fileService, new NullLogService()); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks edit start and complete for existing file', async () => { + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2')); + + await tracker.trackEditStart('/workspace/test.txt'); + await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3')); + await tracker.completeEdit('/workspace/test.txt'); + + const fileEdit = tracker.takeCompletedEdit('/workspace/test.txt'); + assert.ok(fileEdit); + assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); + // Both URIs point to snapshots in the session data directory + const sessionDir = sessionDataService.getSessionDataDirById('test-session'); + assert.ok(fileEdit.beforeURI.startsWith(sessionDir.toString())); + assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); + }); + + test('tracks edit for newly created file (no before content)', async () => { + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + + await tracker.trackEditStart('/workspace/new-file.txt'); + await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent')); + await tracker.completeEdit('/workspace/new-file.txt'); + + const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt'); + assert.ok(fileEdit); + const sessionDir = sessionDataService.getSessionDataDirById('test-session'); + assert.ok(fileEdit.afterURI.startsWith(sessionDir.toString())); + }); + + test('takeCompletedEdit returns undefined for unknown file path', () => { + const result = tracker.takeCompletedEdit('/nonexistent'); + assert.strictEqual(result, undefined); + }); + + test('before and after snapshot content can be read back', async () => { + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, sourceFs)); + await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('original')); + + await tracker.trackEditStart('/workspace/file.ts'); + await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('modified')); + await tracker.completeEdit('/workspace/file.ts'); + + const fileEdit = tracker.takeCompletedEdit('/workspace/file.ts'); + assert.ok(fileEdit); + const beforeContent = await fileService.readFile(URI.parse(fileEdit.beforeURI)); + assert.strictEqual(beforeContent.value.toString(), 'original'); + const afterContent = await fileService.readFile(URI.parse(fileEdit.afterURI)); + assert.strictEqual(afterContent.value.toString(), 'modified'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts new file mode 100644 index 0000000000000..bd20573d73ac0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -0,0 +1,302 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { ToolResultContentType, type IToolCallResult } from '../../common/state/sessionState.js'; + +/** + * General-purpose mock agent for unit tests. Tracks all method calls + * for assertion and exposes {@link fireProgress} to inject progress events. + */ +export class MockAgent implements IAgent { + private readonly _onDidSessionProgress = new Emitter(); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _sessions = new Map(); + private _nextId = 1; + + + readonly sendMessageCalls: { session: URI; prompt: string }[] = []; + readonly disposeSessionCalls: URI[] = []; + readonly abortSessionCalls: URI[] = []; + readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; + readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; + + /** Configurable return value for getSessionMessages. */ + sessionMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + + /** Optional overrides applied to session metadata from listSessions. */ + sessionMetadataOverrides: Partial> = {}; + + constructor(readonly id: AgentProvider = 'mock') { } + + getDescriptor(): IAgentDescriptor { + return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; + } + + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + if (this.id === 'copilot') { + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + } + return []; + } + + async listModels(): Promise { + return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + } + + async listSessions(): Promise { + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), ...this.sessionMetadataOverrides })); + } + + async createSession(_config?: IAgentCreateSessionConfig): Promise { + const rawId = `${this.id}-session-${this._nextId++}`; + const session = AgentSession.uri(this.id, rawId); + this._sessions.set(rawId, session); + return session; + } + + async sendMessage(session: URI, prompt: string): Promise { + this.sendMessageCalls.push({ session, prompt }); + } + + async getSessionMessages(_session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + return this.sessionMessages; + } + + async disposeSession(session: URI): Promise { + this.disposeSessionCalls.push(session); + this._sessions.delete(AgentSession.id(session)); + } + + async abortSession(session: URI): Promise { + this.abortSessionCalls.push(session); + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + this.respondToPermissionCalls.push({ requestId, approved }); + } + + async changeModel(session: URI, model: string): Promise { + this.changeModelCalls.push({ session, model }); + } + + async authenticate(resource: string, token: string): Promise { + this.authenticateCalls.push({ resource, token }); + return true; + } + + async shutdown(): Promise { } + + fireProgress(event: IAgentProgressEvent): void { + this._onDidSessionProgress.fire(event); + } + + dispose(): void { + this._onDidSessionProgress.dispose(); + } +} + +/** + * Well-known URI of a pre-existing session seeded in {@link ScriptedMockAgent}. + * This session appears in `listSessions()` and has message history via + * `getSessionMessages()`, but was never created through the server's + * `handleCreateSession`. It simulates a session from a previous server + * lifetime for testing the restore-on-subscribe path. + */ +export const PRE_EXISTING_SESSION_URI = AgentSession.uri('mock', 'pre-existing-session'); + +export class ScriptedMockAgent implements IAgent { + readonly id: AgentProvider = 'mock'; + + private readonly _onDidSessionProgress = new Emitter(); + readonly onDidSessionProgress = this._onDidSessionProgress.event; + + private readonly _sessions = new Map(); + private _nextId = 1; + + /** + * Message history for the pre-existing session: a single user→assistant + * turn with a tool call. + */ + private readonly _preExistingMessages: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = [ + { type: 'message', role: 'user', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-1', content: 'What files are here?' }, + { type: 'tool_start', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', toolName: 'list_files', displayName: 'List Files', invocationMessage: 'Listing files...' }, + { type: 'tool_complete', session: PRE_EXISTING_SESSION_URI, toolCallId: 'h-tc-1', result: { pastTenseMessage: 'Listed files', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } satisfies IToolCallResult }, + { type: 'message', role: 'assistant', session: PRE_EXISTING_SESSION_URI, messageId: 'h-msg-2', content: 'Here are the files: file1.ts and file2.ts' }, + ]; + + // Track pending permission requests + private readonly _pendingPermissions = new Map void>(); + // Track pending abort callbacks for slow responses + private readonly _pendingAborts = new Map void>(); + + constructor() { + // Seed the pre-existing session so it appears in listSessions() + this._sessions.set(AgentSession.id(PRE_EXISTING_SESSION_URI), PRE_EXISTING_SESSION_URI); + } + + getDescriptor(): IAgentDescriptor { + return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; + } + + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return []; + } + + async listModels(): Promise { + return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; + } + + async listSessions(): Promise { + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined })); + } + + async createSession(_config?: IAgentCreateSessionConfig): Promise { + const rawId = `mock-session-${this._nextId++}`; + const session = AgentSession.uri('mock', rawId); + this._sessions.set(rawId, session); + return session; + } + + async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise { + switch (prompt) { + case 'hello': + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Hello, world!' }, + { type: 'idle', session }, + ]); + break; + + case 'use-tool': + this._fireSequence(session, [ + { type: 'tool_start', session, toolCallId: 'tc-1', toolName: 'echo_tool', displayName: 'Echo Tool', invocationMessage: 'Running echo tool...' }, + { type: 'tool_complete', session, toolCallId: 'tc-1', result: { pastTenseMessage: 'Ran echo tool', content: [{ type: ToolResultContentType.Text, text: 'echoed' }], success: true } }, + { type: 'delta', session, messageId: 'msg-1', content: 'Tool done.' }, + { type: 'idle', session }, + ]); + break; + + case 'error': + this._fireSequence(session, [ + { type: 'error', session, errorType: 'test_error', message: 'Something went wrong' }, + ]); + break; + + case 'permission': { + // Fire tool_start to create the tool, then tool_ready to request confirmation + const toolStartEvent = { + type: 'tool_start' as const, + session, + toolCallId: 'tc-perm-1', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Run a test command', + }; + const toolReadyEvent = { + type: 'tool_ready' as const, + session, + toolCallId: 'tc-perm-1', + invocationMessage: 'Run a test command', + toolInput: 'echo test', + confirmationTitle: 'Run a test command', + }; + setTimeout(() => { + this._onDidSessionProgress.fire(toolStartEvent); + setTimeout(() => this._onDidSessionProgress.fire(toolReadyEvent), 5); + }, 10); + this._pendingPermissions.set('tc-perm-1', (approved) => { + if (approved) { + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Allowed.' }, + { type: 'idle', session }, + ]); + } + }); + break; + } + + case 'with-usage': + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Usage response.' }, + { type: 'usage', session, inputTokens: 100, outputTokens: 50, model: 'mock-model' }, + { type: 'idle', session }, + ]); + break; + + case 'slow': { + // Slow response for cancel testing — fires delta after a long delay + const timer = setTimeout(() => { + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Slow response.' }, + { type: 'idle', session }, + ]); + }, 5000); + this._pendingAborts.set(session.toString(), () => clearTimeout(timer)); + break; + } + + default: + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Unknown prompt: ' + prompt }, + { type: 'idle', session }, + ]); + break; + } + } + + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { + return this._preExistingMessages; + } + return []; + } + + async disposeSession(session: URI): Promise { + this._sessions.delete(AgentSession.id(session)); + } + + async abortSession(session: URI): Promise { + const callback = this._pendingAborts.get(session.toString()); + if (callback) { + this._pendingAborts.delete(session.toString()); + callback(); + } + } + + async changeModel(_session: URI, _model: string): Promise { + // Mock agent doesn't track model state + } + + respondToPermissionRequest(toolCallId: string, approved: boolean): void { + const callback = this._pendingPermissions.get(toolCallId); + if (callback) { + this._pendingPermissions.delete(toolCallId); + callback(approved); + } + } + + async authenticate(_resource: string, _token: string): Promise { + return true; + } + + async shutdown(): Promise { } + + dispose(): void { + this._onDidSessionProgress.dispose(); + } + + private _fireSequence(session: URI, events: IAgentProgressEvent[]): void { + let delay = 0; + for (const event of events) { + delay += 10; + setTimeout(() => this._onDidSessionProgress.fire(event), delay); + } + } +} diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts new file mode 100644 index 0000000000000..9a87a5edb0275 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -0,0 +1,477 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IFetchContentResult } from '../../common/state/protocol/commands.js'; +import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; +import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; +import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; +import { ProtocolServerHandler, type IProtocolSideEffectHandler } from '../../node/protocolServerHandler.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockProtocolTransport implements IProtocolTransport { + private readonly _onMessage = new Emitter(); + readonly onMessage = this._onMessage.event; + private readonly _onDidSend = new Emitter(); + readonly onDidSend = this._onDidSend.event; + private readonly _onClose = new Emitter(); + readonly onClose = this._onClose.event; + + readonly sent: IProtocolMessage[] = []; + + send(message: IProtocolMessage): void { + this.sent.push(message); + this._onDidSend.fire(message); + } + + simulateMessage(msg: IProtocolMessage): void { + this._onMessage.fire(msg); + } + + simulateClose(): void { + this._onClose.fire(); + } + + dispose(): void { + this._onMessage.dispose(); + this._onDidSend.dispose(); + this._onClose.dispose(); + } +} + +class MockProtocolServer implements IProtocolServer { + private readonly _onConnection = new Emitter(); + readonly onConnection = this._onConnection.event; + readonly address = 'mock://test'; + + simulateConnection(transport: IProtocolTransport): void { + this._onConnection.fire(transport); + } + + dispose(): void { + this._onConnection.dispose(); + } +} + +class MockSideEffectHandler implements IProtocolSideEffectHandler { + readonly handledActions: ISessionAction[] = []; + readonly browsedUris: URI[] = []; + readonly browseErrors = new Map(); + + handleAction(action: ISessionAction): void { + this.handledActions.push(action); + } + async handleCreateSession(_command: ICreateSessionParams): Promise { /* session created via state manager */ } + handleDisposeSession(_session: string): void { } + async handleListSessions(): Promise { return []; } + async handleRestoreSession(_session: string): Promise { } + handleGetResourceMetadata() { return { resources: [] }; } + async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } + async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { + this.browsedUris.push(URI.parse(uri)); + const error = this.browseErrors.get(uri); + if (error) { + throw error; + } + return { + entries: [ + { name: 'src', type: 'directory' }, + { name: 'README.md', type: 'file' }, + ], + }; + } + getDefaultDirectory(): string { + return URI.file('/home/testuser').toString(); + } + async handleFetchContent(_uri: string): Promise { + throw new Error('Not implemented'); + } +} + +// ---- Helpers ---------------------------------------------------------------- + +function notification(method: string, params?: unknown): IProtocolMessage { + return { jsonrpc: '2.0', method, params } as IProtocolMessage; +} + +function request(id: number, method: string, params?: unknown): IProtocolMessage { + return { jsonrpc: '2.0', id, method, params } as IProtocolMessage; +} + +function findNotifications(sent: IProtocolMessage[], method: string): IAhpNotification[] { + return sent.filter(isJsonRpcNotification) as IAhpNotification[]; +} + +function findResponse(sent: IProtocolMessage[], id: number): IProtocolMessage | undefined { + return sent.find(isJsonRpcResponse) as IProtocolMessage | undefined; +} + +function waitForResponse(transport: MockProtocolTransport, id: number): Promise { + return Event.toPromise(Event.filter(transport.onDidSend, message => isJsonRpcResponse(message) && message.id === id)); +} + +// ---- Tests ------------------------------------------------------------------ + +suite('ProtocolServerHandler', () => { + + let disposables: DisposableStore; + let stateManager: SessionStateManager; + let server: MockProtocolServer; + let sideEffects: MockSideEffectHandler; + let handler: ProtocolServerHandler; + + const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + + function makeSessionSummary(resource?: string): ISessionSummary { + return { + resource: resource ?? sessionUri, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + } + + function connectClient(clientId: string, initialSubscriptions?: readonly string[]): MockProtocolTransport { + const transport = new MockProtocolTransport(); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId, + initialSubscriptions, + })); + return transport; + } + + setup(() => { + disposables = new DisposableStore(); + stateManager = disposables.add(new SessionStateManager(new NullLogService())); + server = disposables.add(new MockProtocolServer()); + sideEffects = new MockSideEffectHandler(); + disposables.add(handler = new ProtocolServerHandler( + stateManager, + server, + sideEffects, + new NullLogService(), + )); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('handshake returns initialize response', () => { + const transport = connectClient('client-1'); + + const resp = findResponse(transport.sent, 1); + assert.ok(resp, 'should have sent initialize response'); + const result = (resp as { result: IInitializeResult }).result; + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + assert.strictEqual(result.serverSeq, stateManager.serverSeq); + }); + + test('handshake with initialSubscriptions returns snapshots', () => { + stateManager.createSession(makeSessionSummary()); + + const transport = connectClient('client-1', [sessionUri]); + + const resp = findResponse(transport.sent, 1); + assert.ok(resp); + const result = (resp as { result: IInitializeResult }).result; + assert.strictEqual(result.snapshots.length, 1); + assert.strictEqual(result.snapshots[0].resource.toString(), sessionUri.toString()); + }); + + test('subscribe request returns snapshot', async () => { + stateManager.createSession(makeSessionSummary()); + + const transport = connectClient('client-1'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 1); + + transport.simulateMessage(request(1, 'subscribe', { resource: sessionUri })); + const resp = await responsePromise; + + assert.ok(resp, 'should have sent response'); + const result = (resp as unknown as { result: { snapshot: IStateSnapshot } }).result; + assert.strictEqual(result.snapshot.resource.toString(), sessionUri.toString()); + }); + + test('client action is dispatched and echoed', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const transport = connectClient('client-1', [sessionUri]); + transport.sent.length = 0; + + transport.simulateMessage(notification('dispatchAction', { + clientSeq: 1, + action: { + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }, + })); + + const actionMsgs = findNotifications(transport.sent, 'action'); + const turnStarted = actionMsgs.find(m => { + const envelope = m.params as unknown as { action: { type: string } }; + return envelope.action.type === ActionType.SessionTurnStarted; + }); + assert.ok(turnStarted, 'should have echoed turnStarted'); + const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } }; + assert.strictEqual(envelope.origin.clientId, 'client-1'); + assert.strictEqual(envelope.origin.clientSeq, 1); + }); + + test('actions are scoped to subscribed sessions', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const transportA = connectClient('client-a', [sessionUri]); + const transportB = connectClient('client-b'); + + transportA.sent.length = 0; + transportB.sent.length = 0; + + stateManager.dispatchServerAction({ + type: ActionType.SessionTitleChanged, + session: sessionUri, + title: 'New Title', + }); + + assert.strictEqual(findNotifications(transportA.sent, 'action').length, 1); + assert.strictEqual(findNotifications(transportB.sent, 'action').length, 0); + }); + + test('notifications are broadcast to all clients', () => { + const transportA = connectClient('client-a'); + const transportB = connectClient('client-b'); + + transportA.sent.length = 0; + transportB.sent.length = 0; + + stateManager.createSession(makeSessionSummary()); + + assert.strictEqual(findNotifications(transportA.sent, 'notification').length, 1); + assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1); + }); + + test('reconnect replays missed actions', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const transport1 = connectClient('client-r', [sessionUri]); + const resp = findResponse(transport1.sent, 1); + const initSeq = (resp as { result: IInitializeResult }).result.serverSeq; + transport1.simulateClose(); + + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' }); + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-r', + lastSeenServerSeq: initSeq, + subscriptions: [sessionUri], + })); + + const reconnectResp = findResponse(transport2.sent, 1); + assert.ok(reconnectResp, 'should have sent reconnect response'); + const result = (reconnectResp as { result: IReconnectResult }).result; + assert.strictEqual(result.type, 'replay'); + if (result.type === 'replay') { + assert.strictEqual(result.actions.length, 2); + } + }); + + test('reconnect sends fresh snapshots when gap too large', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const transport1 = connectClient('client-g', [sessionUri]); + transport1.simulateClose(); + + for (let i = 0; i < 1100; i++) { + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` }); + } + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-g', + lastSeenServerSeq: 0, + subscriptions: [sessionUri], + })); + + const reconnectResp = findResponse(transport2.sent, 1); + assert.ok(reconnectResp, 'should have sent reconnect response'); + const result = (reconnectResp as { result: IReconnectResult }).result; + assert.strictEqual(result.type, 'snapshot'); + if (result.type === 'snapshot') { + assert.ok(result.snapshots.length > 0, 'should contain snapshots'); + } + }); + + test('client disconnect cleans up', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const transport = connectClient('client-d', [sessionUri]); + transport.sent.length = 0; + + transport.simulateClose(); + + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' }); + + assert.strictEqual(transport.sent.length, 0); + }); + + test('handshake includes defaultDirectory from side effects', () => { + const transport = connectClient('client-home'); + + const resp = findResponse(transport.sent, 1); + assert.ok(resp); + const result = (resp as { result: IInitializeResult }).result; + assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser'); + }); + + test('browseDirectory routes to side effect handler', async () => { + const transport = connectClient('client-browse'); + transport.sent.length = 0; + + const dirUri = URI.file('/home/user/project').toString(); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + const resp = await responsePromise; + + assert.strictEqual(sideEffects.browsedUris.length, 1); + assert.strictEqual(sideEffects.browsedUris[0].path, '/home/user/project'); + + assert.ok(resp); + const result = (resp as unknown as { result: { entries: { name: string; uri: unknown; type: string }[] } }).result; + assert.strictEqual(result.entries.length, 2); + assert.strictEqual(result.entries[0].name, 'src'); + assert.strictEqual(result.entries[0].type, 'directory'); + assert.strictEqual(result.entries[1].name, 'README.md'); + assert.strictEqual(result.entries[1].type, 'file'); + }); + + test('browseDirectory returns a JSON-RPC error when the target is invalid', async () => { + const transport = connectClient('client-browse-error'); + transport.sent.length = 0; + + const dirUri = URI.file('/missing').toString(); + sideEffects.browseErrors.set(dirUri, new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + const resp = await responsePromise as { error?: { code: number; message: string } }; + + assert.ok(resp?.error); + assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR); + assert.match(resp.error!.message, /Directory not found/); + }); + + // ---- Extension methods: auth ---------------------------------------- + + test('getResourceMetadata returns resource metadata via extension request', async () => { + const transport = connectClient('client-metadata'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'getResourceMetadata')); + const resp = await responsePromise as { result?: { resources: unknown[] } }; + + assert.ok(resp?.result); + assert.ok(Array.isArray(resp.result!.resources)); + }); + + test('authenticate returns result via extension request', async () => { + const transport = connectClient('client-auth'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); + const resp = await responsePromise as { result?: { authenticated: boolean } }; + + assert.ok(resp?.result); + assert.strictEqual(resp.result!.authenticated, true); + }); + + test('extension request preserves ProtocolError code and data', async () => { + // Override handleAuthenticate to throw a ProtocolError with data + const origHandler = sideEffects.handleAuthenticate; + sideEffects.handleAuthenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); }; + + const transport = connectClient('client-auth-error'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' })); + const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } }; + + assert.ok(resp?.error); + assert.strictEqual(resp.error!.code, -32007); + assert.strictEqual(resp.error!.message, 'Auth required'); + assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' }); + + sideEffects.handleAuthenticate = origHandler; + }); + + // ---- Connection count event ----------------------------------------- + + test('onDidChangeConnectionCount fires on connect and disconnect', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + const transport = connectClient('client-count-1'); + connectClient('client-count-2'); + transport.simulateClose(); + + assert.deepStrictEqual(counts, [1, 2, 1]); + }); + + test('onDidChangeConnectionCount is not decremented by stale reconnect close', () => { + const counts: number[] = []; + disposables.add(handler.onDidChangeConnectionCount(c => counts.push(c))); + + // Connect + const transport1 = connectClient('client-rc'); + assert.deepStrictEqual(counts, [1]); + + // Reconnect with same clientId (new transport) + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + transport2.simulateMessage(request(1, 'reconnect', { + clientId: 'client-rc', + lastSeenServerSeq: 0, + subscriptions: [], + })); + // Count is unchanged because same clientId was overwritten + assert.deepStrictEqual(counts, [1, 1]); + + // Old transport closes - should NOT decrement since it's stale + transport1.simulateClose(); + assert.deepStrictEqual(counts, [1, 1]); + + // New transport closes - should decrement + transport2.simulateClose(); + assert.deepStrictEqual(counts, [1, 1, 0]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts new file mode 100644 index 0000000000000..100b83596740a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts @@ -0,0 +1,716 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ChildProcess, fork } from 'child_process'; +import { fileURLToPath } from 'url'; +import { WebSocket } from 'ws'; +import { URI } from '../../../../base/common/uri.js'; +import { ISubscribeResult } from '../../common/state/protocol/commands.js'; +import type { IActionEnvelope, IResponsePartAction, ISessionAddedNotification, ISessionRemovedNotification, IUsageAction } from '../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; +import { + isJsonRpcNotification, + isJsonRpcResponse, + JSON_RPC_PARSE_ERROR, + type IAhpNotification, + type IFetchTurnsResult, + type IInitializeResult, + type IJsonRpcErrorResponse, + type IJsonRpcSuccessResponse, + type IListSessionsResult, + type INotificationBroadcastParams, + type IProtocolMessage, + type IReconnectResult +} from '../../common/state/sessionProtocol.js'; +import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; +import { PRE_EXISTING_SESSION_URI } from './mockAgent.js'; + +// ---- JSON-RPC test client --------------------------------------------------- + +interface IPendingCall { + resolve: (result: unknown) => void; + reject: (err: Error) => void; +} + +class TestProtocolClient { + private readonly _ws: WebSocket; + private _nextId = 1; + private readonly _pendingCalls = new Map(); + private readonly _notifications: IAhpNotification[] = []; + private readonly _notifWaiters: { predicate: (n: IAhpNotification) => boolean; resolve: (n: IAhpNotification) => void; reject: (err: Error) => void }[] = []; + + constructor(port: number) { + this._ws = new WebSocket(`ws://127.0.0.1:${port}`); + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this._ws.on('open', () => { + this._ws.on('message', (data: Buffer | string) => { + const text = typeof data === 'string' ? data : data.toString('utf-8'); + const msg = JSON.parse(text); + this._handleMessage(msg); + }); + resolve(); + }); + this._ws.on('error', reject); + }); + } + + private _handleMessage(msg: IProtocolMessage): void { + if (isJsonRpcResponse(msg)) { + // JSON-RPC response — resolve pending call + const pending = this._pendingCalls.get(msg.id); + if (pending) { + this._pendingCalls.delete(msg.id); + const errResp = msg as IJsonRpcErrorResponse; + if (errResp.error) { + pending.reject(new Error(errResp.error.message)); + } else { + pending.resolve((msg as IJsonRpcSuccessResponse).result); + } + } + } else if (isJsonRpcNotification(msg)) { + // JSON-RPC notification from server + const notif = msg; + // Check waiters first + for (let i = this._notifWaiters.length - 1; i >= 0; i--) { + if (this._notifWaiters[i].predicate(notif)) { + const waiter = this._notifWaiters.splice(i, 1)[0]; + waiter.resolve(notif); + } + } + this._notifications.push(notif); + } + } + + /** Send a JSON-RPC notification (fire-and-forget). */ + notify(method: string, params?: unknown): void { + this._ws.send(JSON.stringify({ jsonrpc: '2.0', method, params })); + } + + /** Send a JSON-RPC request and await the response. */ + call(method: string, params?: unknown, timeoutMs = 5000): Promise { + const id = this._nextId++; + this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pendingCalls.delete(id); + reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`)); + }, timeoutMs); + + this._pendingCalls.set(id, { + resolve: result => { clearTimeout(timer); resolve(result as T); }, + reject: err => { clearTimeout(timer); reject(err); }, + }); + }); + } + + /** Wait for a server notification matching a predicate. */ + waitForNotification(predicate: (n: IAhpNotification) => boolean, timeoutMs = 5000): Promise { + const existing = this._notifications.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); + if (idx >= 0) { + this._notifWaiters.splice(idx, 1); + } + reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); + }, timeoutMs); + + this._notifWaiters.push({ + predicate, + resolve: n => { clearTimeout(timer); resolve(n); }, + reject, + }); + }); + } + + /** Return all received notifications matching a predicate. */ + receivedNotifications(predicate?: (n: IAhpNotification) => boolean): IAhpNotification[] { + return predicate ? this._notifications.filter(predicate) : [...this._notifications]; + } + + /** Send a raw string over the WebSocket without JSON serialization. */ + sendRaw(data: string): void { + this._ws.send(data); + } + + /** Wait for the next raw message from the server. */ + waitForRawMessage(timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for raw message (${timeoutMs}ms)`)); + }, timeoutMs); + const onMsg = (data: Buffer | string) => { + cleanup(); + const text = typeof data === 'string' ? data : data.toString('utf-8'); + resolve(JSON.parse(text)); + }; + const cleanup = () => { + clearTimeout(timer); + this._ws.removeListener('message', onMsg); + }; + this._ws.on('message', onMsg); + }); + } + + close(): void { + for (const w of this._notifWaiters) { + w.reject(new Error('Client closed')); + } + this._notifWaiters.length = 0; + for (const [, p] of this._pendingCalls) { + p.reject(new Error('Client closed')); + } + this._pendingCalls.clear(); + this._ws.close(); + } + + clearReceived(): void { + this._notifications.length = 0; + } +} + +// ---- Server process lifecycle ----------------------------------------------- + +async function startServer(): Promise<{ process: ChildProcess; port: number }> { + return new Promise((resolve, reject) => { + const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); + const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0', '--without-connection-token'], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + const timeout = setTimeout(() => { + child.kill(); + reject(new Error('Server startup timed out')); + }, 10_000); + + child.stdout!.on('data', (data: Buffer) => { + const text = data.toString(); + const match = text.match(/READY:(\d+)/); + if (match) { + clearTimeout(timeout); + resolve({ process: child, port: parseInt(match[1], 10) }); + } + }); + + child.stderr!.on('data', () => { + // Intentionally swallowed - the test runner fails if console.error is used. + }); + + child.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + + child.on('exit', code => { + clearTimeout(timeout); + reject(new Error(`Server exited prematurely with code ${code}`)); + }); + }); +} + +// ---- Helpers ---------------------------------------------------------------- + +let sessionCounter = 0; + +function nextSessionUri(): string { + return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); +} + +function isActionNotification(n: IAhpNotification, actionType: string): boolean { + if (n.method !== 'action') { + return false; + } + const envelope = n.params as unknown as IActionEnvelope; + return envelope.action.type === actionType; +} + +function getActionEnvelope(n: IAhpNotification): IActionEnvelope { + return n.params as unknown as IActionEnvelope; +} + +/** Perform handshake, create a session, subscribe, and return its URI. */ +async function createAndSubscribeSession(c: TestProtocolClient, clientId: string): Promise { + await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); + + await c.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const notif = await c.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + + await c.call('subscribe', { resource: realSessionUri }); + c.clearReceived(); + + return realSessionUri; +} + +function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { + c.notify('dispatchAction', { + clientSeq, + action: { + type: 'session/turnStarted', + session, + turnId, + userMessage: { text }, + }, + }); +} + +// ---- Test suite ------------------------------------------------------------- + +suite('Protocol WebSocket E2E', function () { + + let server: { process: ChildProcess; port: number }; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + // 1. Handshake + test('handshake returns initialize response with protocol version', async function () { + this.timeout(5_000); + + const result = await client.call('initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId: 'test-handshake', + initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' }).toString()], + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + assert.ok(result.serverSeq >= 0); + assert.ok(result.snapshots.length >= 1, 'should have root state snapshot'); + }); + + // 2. Create session + test('create session triggers sessionAdded notification', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' }); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock'); + assert.strictEqual(notification.summary.provider, 'mock'); + }); + + // 3. Send message and receive response + test('send message and receive responsePart + turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-send-message'); + dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1); + + const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; + assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); + assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Hello, world!'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 4. Tool invocation lifecycle + test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); + dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); + + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + const tcAction = getActionEnvelope(toolComplete).action; + if (tcAction.type === 'session/toolCallComplete') { + assert.strictEqual(tcAction.result.success, true); + } + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 5. Error handling + test('error prompt triggers session/error', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-error'); + dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1); + + const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error')); + const errorAction = getActionEnvelope(errorNotif).action; + if (errorAction.type === 'session/error') { + assert.strictEqual(errorAction.error.message, 'Something went wrong'); + } + }); + + // 6. Permission flow (via tool_ready confirmation) + test('permission request → resolve → response', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-permission'); + dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1); + + // The mock agent now fires tool_start + tool_ready instead of permission_request + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm the tool call + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-perm', + toolCallId: 'tc-perm-1', + approved: true, + }, + }); + + const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; + assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); + assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Allowed.'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // 7. Session list + test('listSessions returns sessions', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' }); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + + const result = await client.call('listSessions'); + assert.ok(Array.isArray(result.items)); + assert.ok(result.items.length >= 1, 'should have at least one session'); + }); + + // 8. Reconnect + test('reconnect replays missed actions', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-reconnect'); + dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const allActions = client.receivedNotifications(n => n.method === 'action'); + assert.ok(allActions.length > 0); + const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1; + + client.close(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + const result = await client2.call('reconnect', { + clientId: 'test-reconnect', + lastSeenServerSeq: missedFromSeq, + subscriptions: [sessionUri], + }); + + assert.ok(result.type === 'replay' || result.type === 'snapshot', 'should receive replay or snapshot'); + if (result.type === 'replay') { + assert.ok(result.actions.length > 0, 'should have replayed actions'); + } + + client2.close(); + }); + + // ---- Gap tests: functionality bugs ---------------------------------------- + + test('usage info is captured on completed turn', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-usage'); + dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1); + + const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage')); + const usageAction = getActionEnvelope(usageNotif).action as IUsageAction; + assert.strictEqual(usageAction.usage.inputTokens, 100); + assert.strictEqual(usageAction.usage.outputTokens, 50); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + const turn = state.turns[state.turns.length - 1]; + assert.ok(turn.usage); + assert.strictEqual(turn.usage!.inputTokens, 100); + assert.strictEqual(turn.usage!.outputTokens, 50); + }); + + test('modifiedAt updates on turn completion', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); + + const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); + const initialModifiedAt = (initialSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + + await new Promise(resolve => setTimeout(resolve, 50)); + + dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); + const updatedModifiedAt = (updatedSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + assert.ok(updatedModifiedAt >= initialModifiedAt); + }); + + test('createSession with invalid provider does not crash server', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' }); + + // This should return a JSON-RPC error + let gotError = false; + try { + await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should have received an error for invalid provider'); + + // Server should still be functional + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.ok(notif); + }); + + test('fetchTurns returns completed turn history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); + assert.ok(result.turns.length >= 2); + assert.strictEqual(typeof result.hasMore, 'boolean'); + }); + + // ---- Gap tests: coverage --------------------------------------------------- + + test('dispose session sends sessionRemoved notification', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-dispose'); + await client.call('disposeSession', { session: sessionUri }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + assert.strictEqual(removed.session.toString(), sessionUri.toString()); + }); + + test('cancel turn stops in-progress processing', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cancel'); + dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); + + client.notify('dispatchAction', { + clientSeq: 2, + action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); + }); + + test('multiple sequential turns accumulate in history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns'); + + dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); + assert.strictEqual(state.turns[0].id, 'turn-m1'); + assert.strictEqual(state.turns[1].id, 'turn-m2'); + }); + + test('two clients on same session both see actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1); + + const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + assert.ok(d1); + assert.ok(d2); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('unsubscribe stops receiving session actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe'); + client.notify('unsubscribe', { resource: sessionUri }); + await new Promise(resolve => setTimeout(resolve, 100)); + client.clearReceived(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' }); + await client2.call('subscribe', { resource: sessionUri }); + + dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + await new Promise(resolve => setTimeout(resolve, 300)); + const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/')); + assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions'); + + client2.close(); + }); + + test('change model within session updates state', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-change-model'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { type: 'session/modelChanged', session: sessionUri, model: 'new-mock-model' }, + }); + + const modelChanged = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); + const action = getActionEnvelope(modelChanged).action; + assert.strictEqual(action.type, 'session/modelChanged'); + if (action.type === 'session/modelChanged') { + assert.strictEqual((action as { model: string }).model, 'new-mock-model'); + } + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.model, 'new-mock-model'); + }); + + // ---- Session restore: subscribe to a session from a previous server lifetime + + test('subscribe to a pre-existing session restores turns from agent history', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' }); + + // The mock agent seeds a pre-existing session that was never created + // through the server's handleCreateSession -- simulating a session + // from a previous server lifetime. + const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); + const list = await client.call('listSessions'); + const preExisting = list.items.find(s => s.resource === preExistingUri); + assert.ok(preExisting, 'listSessions should include the pre-existing session'); + + // Clear notifications so we can verify no duplicate sessionAdded fires. + client.clearReceived(); + + // Subscribing to this session should trigger the restore path: the + // server fetches message history from the agent and reconstructs turns. + const result = await client.call('subscribe', { resource: preExistingUri }); + const state = result.snapshot.state as ISessionState; + + assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); + assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); + + const turn = state.turns[0]; + assert.strictEqual(turn.userMessage.text, 'What files are here?'); + assert.strictEqual(turn.state, 'complete'); + const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); + assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files'); + const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts'); + + // Restoring should NOT emit a duplicate sessionAdded notification + // (the session is already known to clients via listSessions). + await new Promise(resolve => setTimeout(resolve, 200)); + const sessionAddedNotifs = client.receivedNotifications(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded'); + }); + + test('malformed JSON message returns parse error', async function () { + this.timeout(10_000); + + const raw = new TestProtocolClient(server.port); + await raw.connect(); + + const responsePromise = raw.waitForRawMessage(); + raw.sendRaw('this is not valid json{{{'); + + const response = await responsePromise as IJsonRpcErrorResponse; + assert.strictEqual(response.jsonrpc, '2.0'); + assert.strictEqual(response.id, null); + assert.strictEqual(response.error.code, JSON_RPC_PARSE_ERROR); + + raw.close(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts new file mode 100644 index 0000000000000..5adefa25f084d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentSession } from '../../common/agentService.js'; +import { SessionDataService } from '../../node/sessionDataService.js'; + +suite('SessionDataService', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let service: SessionDataService; + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + service = new SessionDataService(basePath, fileService, new NullLogService()); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getSessionDataDir returns correct URI', () => { + const session = AgentSession.uri('copilot', 'abc-123'); + const dir = service.getSessionDataDir(session); + assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'abc-123').toString()); + }); + + test('getSessionDataDir sanitizes unsafe characters', () => { + const session = AgentSession.uri('copilot', 'foo/bar:baz\\qux'); + const dir = service.getSessionDataDir(session); + assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString()); + }); + + test('deleteSessionData removes directory', async () => { + const session = AgentSession.uri('copilot', 'session-1'); + const dir = service.getSessionDataDir(session); + await fileService.createFolder(dir); + await fileService.writeFile(URI.joinPath(dir, 'snapshot.json'), VSBuffer.fromString('{}')); + + assert.ok(await fileService.exists(dir)); + await service.deleteSessionData(session); + assert.ok(!(await fileService.exists(dir))); + }); + + test('deleteSessionData is a no-op when directory does not exist', async () => { + const session = AgentSession.uri('copilot', 'nonexistent'); + // Should not throw + await service.deleteSessionData(session); + }); + + test('cleanupOrphanedData deletes orphans but keeps known sessions', async () => { + const baseDir = URI.joinPath(basePath, 'agentSessionData'); + await fileService.createFolder(URI.joinPath(baseDir, 'keep-1')); + await fileService.createFolder(URI.joinPath(baseDir, 'keep-2')); + await fileService.createFolder(URI.joinPath(baseDir, 'orphan-1')); + await fileService.createFolder(URI.joinPath(baseDir, 'orphan-2')); + + await service.cleanupOrphanedData(new Set(['keep-1', 'keep-2'])); + + assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-1'))); + assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-2'))); + assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-1')))); + assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-2')))); + }); + + test('cleanupOrphanedData is a no-op when base directory does not exist', async () => { + // Should not throw + await service.cleanupOrphanedData(new Set()); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts new file mode 100644 index 0000000000000..852e8cf58fcbd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; +import { ISessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, type IMarkdownResponsePart, type ISessionState } from '../../common/state/sessionState.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; + +suite('SessionStateManager', () => { + + let disposables: DisposableStore; + let manager: SessionStateManager; + const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); + + function makeSessionSummary(resource?: string): ISessionSummary { + return { + resource: resource ?? sessionUri, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + } + + setup(() => { + disposables = new DisposableStore(); + manager = disposables.add(new SessionStateManager(new NullLogService())); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('createSession creates initial state with lifecycle Creating', () => { + const state = manager.createSession(makeSessionSummary()); + assert.strictEqual(state.lifecycle, SessionLifecycle.Creating); + assert.strictEqual(state.turns.length, 0); + assert.strictEqual(state.activeTurn, undefined); + assert.strictEqual(state.summary.resource.toString(), sessionUri.toString()); + }); + + test('getSnapshot returns undefined for unknown session', () => { + const unknown = URI.from({ scheme: 'copilot', path: '/unknown' }).toString(); + const snapshot = manager.getSnapshot(unknown); + assert.strictEqual(snapshot, undefined); + }); + + test('getSnapshot returns root snapshot', () => { + const snapshot = manager.getSnapshot(ROOT_STATE_URI); + assert.ok(snapshot); + assert.strictEqual(snapshot.resource.toString(), ROOT_STATE_URI.toString()); + assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + }); + + test('getSnapshot returns session snapshot after creation', () => { + manager.createSession(makeSessionSummary()); + const snapshot = manager.getSnapshot(sessionUri); + assert.ok(snapshot); + assert.strictEqual(snapshot.resource.toString(), sessionUri.toString()); + assert.strictEqual((snapshot.state as ISessionState).lifecycle, SessionLifecycle.Creating); + }); + + test('dispatchServerAction applies action and emits envelope', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ + type: ActionType.SessionReady, + session: sessionUri, + }); + + const state = manager.getSessionState(sessionUri); + assert.ok(state); + assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); + + assert.strictEqual(envelopes.length, 1); + assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady); + assert.strictEqual(envelopes[0].serverSeq, 1); + assert.strictEqual(envelopes[0].origin, undefined); + }); + + test('serverSeq increments monotonically', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' }); + + assert.strictEqual(envelopes.length, 2); + assert.strictEqual(envelopes[0].serverSeq, 1); + assert.strictEqual(envelopes[1].serverSeq, 2); + assert.ok(envelopes[1].serverSeq > envelopes[0].serverSeq); + }); + + test('dispatchClientAction includes origin in envelope', () => { + manager.createSession(makeSessionSummary()); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const origin = { clientId: 'renderer-1', clientSeq: 42 }; + manager.dispatchClientAction( + { type: ActionType.SessionReady, session: sessionUri }, + origin, + ); + + assert.strictEqual(envelopes.length, 1); + assert.deepStrictEqual(envelopes[0].origin, origin); + }); + + test('removeSession clears state and emits notification', () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.removeSession(sessionUri); + + assert.strictEqual(manager.getSessionState(sessionUri), undefined); + assert.strictEqual(manager.getSnapshot(sessionUri), undefined); + assert.strictEqual(notifications.length, 1); + assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved); + }); + + test('createSession emits sessionAdded notification', () => { + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.createSession(makeSessionSummary()); + + assert.strictEqual(notifications.length, 1); + assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); + }); + + test('getActiveTurnId returns active turn id after turnStarted', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + assert.strictEqual(manager.getActiveTurnId(sessionUri), 'turn-1'); + }); + + test('root state starts with activeSessions: 0', () => { + const snapshot = manager.getSnapshot(ROOT_STATE_URI); + assert.ok(snapshot); + assert.deepStrictEqual(snapshot.state, { agents: [], activeSessions: 0 }); + }); + + test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); + assert.strictEqual(activeChanged.length, 1); + assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1); + assert.strictEqual(manager.rootState.activeSessions, 1); + }); + + test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'hello' }, + }); + + const envelopes: IActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: sessionUri, + turnId: 'turn-1', + }); + + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); + assert.strictEqual(activeChanged.length, 1); + assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); + assert.strictEqual(manager.rootState.activeSessions, 0); + }); + + test('activeSessions reflects concurrent turn count across sessions', () => { + const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); + manager.createSession(makeSessionSummary(sessionUri)); + manager.createSession(makeSessionSummary(session2Uri)); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri }); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'a' }, + }); + manager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: session2Uri, + turnId: 'turn-2', + userMessage: { text: 'b' }, + }); + assert.strictEqual(manager.rootState.activeSessions, 2); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: sessionUri, + turnId: 'turn-1', + }); + assert.strictEqual(manager.rootState.activeSessions, 1); + + manager.dispatchServerAction({ + type: ActionType.SessionTurnComplete, + session: session2Uri, + turnId: 'turn-2', + }); + assert.strictEqual(manager.rootState.activeSessions, 0); + }); + + test('restoreSession creates session in Ready state with pre-populated turns', () => { + const turns = [ + { + id: 'turn-1', + userMessage: { text: 'hello' }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies IMarkdownResponsePart], + usage: undefined, + state: TurnState.Complete, + }, + ]; + + const state = manager.restoreSession(makeSessionSummary(), turns); + assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); + assert.strictEqual(state.turns.length, 1); + assert.strictEqual(state.turns[0].userMessage.text, 'hello'); + assert.strictEqual((state.turns[0].responseParts[0] as IMarkdownResponsePart).content, 'world'); + }); + + test('restoreSession returns existing state for duplicate session', () => { + manager.createSession(makeSessionSummary()); + const existing = manager.getSessionState(sessionUri); + + const state = manager.restoreSession(makeSessionSummary(), []); + assert.strictEqual(state, existing); + }); + + test('restoreSession does not emit sessionAdded notification', () => { + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.restoreSession(makeSessionSummary(), []); + + assert.strictEqual(notifications.length, 0, 'should not emit notification for restored sessions'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh b/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh new file mode 100755 index 0000000000000..c56157e35407c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/testRemoteAgentHost.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# End-to-end smoke test for the remote agent host feature. +# +# Launches a standalone agent host server, starts the Sessions app with +# `chat.remoteAgentHosts` pre-configured to connect to it, validates that +# the Sessions app discovers the remote, and sends a chat message via the +# remote session target. +# +# Usage: +# ./testRemoteAgentHost.sh +# ./testRemoteAgentHost.sh "Hello, what can you do?" +# ./testRemoteAgentHost.sh --server-port 9090 --cdp-port 9225 "Explain this" +# +# Options: +# --server-port Agent host WebSocket port (default: 8081) +# --cdp-port CDP debugging port for Sessions app (default: 9224) +# --timeout Seconds to wait for response (default: 60) +# --no-kill Don't kill processes after the test +# --skip-message Only validate connection, don't send a message +# +# Requires: agent-browser (npm install -g agent-browser, or use npx) + +set -e + +ROOT="$(cd "$(dirname "$0")/../../../../../.." && pwd)" +SERVER_PORT=8081 +CDP_PORT=9224 +RESPONSE_TIMEOUT=60 +KILL_AFTER=true +SKIP_MESSAGE=false +MESSAGE="" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --server-port) + SERVER_PORT="$2" + shift 2 + ;; + --cdp-port) + CDP_PORT="$2" + shift 2 + ;; + --timeout) + RESPONSE_TIMEOUT="$2" + shift 2 + ;; + --no-kill) + KILL_AFTER=false + shift + ;; + --skip-message) + SKIP_MESSAGE=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + MESSAGE="$1" + shift + ;; + esac +done + +if [ -z "$MESSAGE" ] && [ "$SKIP_MESSAGE" = false ]; then + MESSAGE="Hello, what can you do?" +fi + +AB="npx agent-browser" +SERVER_PID="" +USERDATA_DIR="" + +cleanup() { + echo "" >&2 + echo "=== Cleanup ===" >&2 + + $AB close 2>/dev/null || true + + if [ "$KILL_AFTER" = true ]; then + # Kill Sessions app + local CDP_PIDS + CDP_PIDS=$(lsof -t -i :"$CDP_PORT" 2>/dev/null || true) + if [ -n "$CDP_PIDS" ]; then + echo "Killing Sessions app (CDP port $CDP_PORT)..." >&2 + kill $CDP_PIDS 2>/dev/null || true + fi + + # Kill agent host server + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + echo "Killing agent host server (PID $SERVER_PID)..." >&2 + kill "$SERVER_PID" 2>/dev/null || true + # Give it a moment, then force-kill if still alive + sleep 0.5 + if kill -0 "$SERVER_PID" 2>/dev/null; then + kill -9 "$SERVER_PID" 2>/dev/null || true + fi + fi + + # Kill the sleep process that was keeping the server's stdin open. + # It was started via process substitution and is a child of this shell. + local SLEEP_PIDS + SLEEP_PIDS=$(pgrep -P $$ -f "sleep 86400" 2>/dev/null || true) + if [ -n "$SLEEP_PIDS" ]; then + kill $SLEEP_PIDS 2>/dev/null || true + fi + + # Also kill by port in case PID tracking missed it + local SERVER_PIDS + SERVER_PIDS=$(lsof -t -i :"$SERVER_PORT" 2>/dev/null || true) + if [ -n "$SERVER_PIDS" ]; then + kill $SERVER_PIDS 2>/dev/null || true + fi + fi + + # Clean up temp user data dir + if [ -n "$USERDATA_DIR" ] && [ -d "$USERDATA_DIR" ]; then + echo "Cleaning up temp user data dir: $USERDATA_DIR" >&2 + rm -rf "$USERDATA_DIR" + fi +} +trap cleanup EXIT + +# ---- Step 1: Start the agent host server ------------------------------------ + +echo "=== Step 1: Starting agent host server on port $SERVER_PORT ===" >&2 + +# Ensure port is free +if lsof -i :"$SERVER_PORT" >/dev/null 2>&1; then + echo "ERROR: Port $SERVER_PORT already in use" >&2 + exit 1 +fi +if lsof -i :"$CDP_PORT" >/dev/null 2>&1; then + echo "ERROR: CDP port $CDP_PORT already in use" >&2 + exit 1 +fi + +cd "$ROOT" + +# Start the server directly using Node (not via code-agent-host.sh which +# spawns a subprocess tree that's harder to manage in background mode). +# Use system node rather than the VS Code-managed node binary which may +# not have been downloaded yet. +SERVER_ENTRY="$ROOT/out/vs/platform/agentHost/node/agentHostServerMain.js" + +if [ ! -f "$SERVER_ENTRY" ]; then + echo "ERROR: Server entry point not found: $SERVER_ENTRY" >&2 + echo " Run the build first (npm run compile or the watch task)" >&2 + exit 1 +fi + +# Use a temp file for output and poll for READY. +# The server stays alive until stdin closes (process.stdin.on('end', shutdown)), +# so we keep stdin open using a process substitution with a long sleep. +# This avoids FIFOs and leaked file descriptors that caused cleanup hangs. +SERVER_READY_FILE=$(mktemp) + +node "$SERVER_ENTRY" --port "$SERVER_PORT" --quiet --enable-mock-agent \ + < <(sleep 86400) > "$SERVER_READY_FILE" 2>/dev/null & +SERVER_PID=$! + +echo "Server PID: $SERVER_PID" >&2 + +# Poll the output file for the READY line +echo "Waiting for server to start..." >&2 +SERVER_ADDR="" +for i in $(seq 1 30); do + READY_MATCH=$(grep -o 'READY:[0-9]*' "$SERVER_READY_FILE" 2>/dev/null || true) + if [ -n "$READY_MATCH" ]; then + READY_PORT=$(echo "$READY_MATCH" | cut -d: -f2) + SERVER_ADDR="ws://127.0.0.1:${READY_PORT}" + break + fi + sleep 1 +done +rm -f "$SERVER_READY_FILE" + +if [ -z "$SERVER_ADDR" ]; then + echo "ERROR: Server did not start within 30 seconds" >&2 + exit 1 +fi + +echo "Agent host server ready at $SERVER_ADDR" >&2 + +# ---- Step 2: Prepare user data with remote agent host setting --------------- + +echo "=== Step 2: Configuring Sessions app settings ===" >&2 + +# We use 127.0.0.1: as the address (strip ws:// prefix) +REMOTE_ADDR=$(echo "$SERVER_ADDR" | sed 's|^ws://||') + +USERDATA_DIR=$(mktemp -d) +SETTINGS_DIR="$USERDATA_DIR/User" +mkdir -p "$SETTINGS_DIR" + +cat > "$SETTINGS_DIR/settings.json" << EOF +{ + "chat.remoteAgentHosts": [ + { + "address": "$REMOTE_ADDR", + "name": "Test Remote Agent" + } + ], + "window.titleBarStyle": "custom" +} +EOF + +echo "Settings configured: $SETTINGS_DIR/settings.json" >&2 +echo " Remote address: $REMOTE_ADDR" >&2 + +# ---- Step 3: Launch Sessions app -------------------------------------------- + +echo "=== Step 3: Launching Sessions app ===" >&2 + +cd "$ROOT" +# Unset ELECTRON_RUN_AS_NODE to ensure the app launches as Electron, not Node. +VSCODE_SKIP_PRELAUNCH=1 ELECTRON_RUN_AS_NODE= ./scripts/code.sh \ + --sessions \ + --skip-sessions-welcome \ + --remote-debugging-port="$CDP_PORT" \ + --user-data-dir="$USERDATA_DIR" \ + &>/dev/null & + +echo "Waiting for Sessions app to start..." >&2 +for i in $(seq 1 30); do + if $AB connect "$CDP_PORT" 2>/dev/null; then + break + fi + sleep 2 + if [ "$i" -eq 30 ]; then + echo "ERROR: Sessions app did not start within 60 seconds" >&2 + exit 1 + fi +done + +echo "Connected to Sessions app via CDP" >&2 + +# Give the app a moment to initialize fully +sleep 3 + +# ---- Step 4: Validate the remote connection appeared ------------------------- + +echo "=== Step 4: Validating remote agent host connection ===" >&2 + +# Wait for the remote to appear as a session target +REMOTE_FOUND=false +for i in $(seq 1 20); do + SNAPSHOT=$($AB snapshot -i 2>&1 || true) + + # Look for the remote in the session target picker or any UI element + if echo "$SNAPSHOT" | grep -qi "Test Remote Agent\|remote.*agent"; then + REMOTE_FOUND=true + break + fi + + # Also check via DOM for the session target radio containing our remote name + REMOTE_CHECK=$($AB eval ' +(() => { + const text = document.body.innerText || ""; + if (text.includes("Test Remote Agent")) return "found"; + // Check radio buttons in the session target picker + const buttons = document.querySelectorAll(".monaco-custom-radio > .monaco-button"); + for (const btn of buttons) { + if (btn.textContent?.includes("Test Remote Agent")) return "found"; + } + return "not found"; +})()' 2>&1 || true) + + if echo "$REMOTE_CHECK" | grep -q "found"; then + REMOTE_FOUND=true + break + fi + + sleep 2 +done + +if [ "$REMOTE_FOUND" = true ]; then + echo "SUCCESS: Remote agent host 'Test Remote Agent' is visible in the Sessions app" >&2 +else + echo "ERROR: Could not find remote agent host 'Test Remote Agent' in the Sessions app UI" >&2 + echo "Snapshot excerpt:" >&2 + echo "$SNAPSHOT" | head -30 >&2 + exit 1 +fi + +# ---- Step 5: Send a message (optional) -------------------------------------- + +if [ "$SKIP_MESSAGE" = true ]; then + echo "=== Skipping message send (--skip-message) ===" >&2 + echo "Remote agent host test completed successfully." >&2 + exit 0 +fi + +echo "=== Step 5: Switching to remote session target and sending message ===" >&2 + +# Take a screenshot before interaction +SCREENSHOT_DIR="/tmp/remote-agent-test-$(date +%Y-%m-%dT%H-%M-%S)" +mkdir -p "$SCREENSHOT_DIR" +$AB screenshot "$SCREENSHOT_DIR/01-before-interaction.png" 2>/dev/null || true + +# Click the session target radio button for the remote agent host +CLICK_RESULT=$($AB eval ' +(() => { + const buttons = document.querySelectorAll(".monaco-custom-radio > .monaco-button"); + for (const btn of buttons) { + if (btn.textContent?.includes("Test Remote Agent")) { + btn.click(); + return "clicked"; + } + } + return "not found"; +})()' 2>&1 || true) + +if echo "$CLICK_RESULT" | grep -q "not found"; then + echo "ERROR: Could not find 'Test Remote Agent' radio button to click" >&2 + $AB screenshot "$SCREENSHOT_DIR/02-click-failed.png" 2>/dev/null || true + exit 1 +fi +echo "Switched to remote session target" >&2 + +sleep 1 + +$AB screenshot "$SCREENSHOT_DIR/02-after-target-switch.png" 2>/dev/null || true + +# Fill in the remote folder path input (required for remote sessions) +echo "Setting remote folder path..." >&2 +FOLDER_SET=$($AB eval ' +(() => { + const input = document.querySelector("input.sessions-chat-remote-folder-text"); + if (!input) return "no input"; + input.focus(); + return "focused"; +})()' 2>&1 || true) + +if echo "$FOLDER_SET" | grep -q "no input"; then + echo "WARNING: Could not find remote folder input, continuing anyway..." >&2 +else + # Type a folder path using clipboard paste for speed + echo "/tmp" | pbcopy + $AB press Meta+a 2>/dev/null || true + $AB press Meta+v 2>/dev/null || true + sleep 0.3 + # Press Enter to confirm the folder path + $AB press Enter 2>/dev/null || true + sleep 0.5 + echo "Remote folder path set to /tmp" >&2 +fi + +$AB screenshot "$SCREENSHOT_DIR/03-after-folder.png" 2>/dev/null || true + +# Type the message into the chat editor using clipboard paste for speed +echo "Typing message: $MESSAGE" >&2 +$AB eval ' +(() => { + // Focus the chat editor textarea + const textarea = document.querySelector(".new-chat-widget .monaco-editor textarea"); + if (textarea) { textarea.focus(); return "focused editor"; } + return "editor not found"; +})()' 2>/dev/null || true + +sleep 0.3 +echo -n "$MESSAGE" | pbcopy +$AB press Meta+v 2>/dev/null || true +sleep 0.5 + +$AB screenshot "$SCREENSHOT_DIR/04-after-type.png" 2>/dev/null || true + +# Send the message via the send button or keyboard +$AB eval ' +(() => { + // Try clicking the send button directly + const sendBtn = document.querySelector(".new-chat-widget .codicon-send"); + if (sendBtn) { + const btn = sendBtn.closest("a, button, .monaco-button"); + if (btn) { btn.click(); return "clicked send"; } + } + return "send button not found"; +})()' 2>/dev/null || true + +$AB screenshot "$SCREENSHOT_DIR/05-after-send.png" 2>/dev/null || true + +# ---- Step 6: Wait for response ---------------------------------------------- + +echo "Waiting for response (timeout: ${RESPONSE_TIMEOUT}s)..." >&2 + +RESPONSE="" +for i in $(seq 1 "$RESPONSE_TIMEOUT"); do + sleep 1 + + # Check for response content in the chat area + RESPONSE=$($AB eval ' +(() => { + // Sessions app uses the main chat area (not sidebar) + const items = document.querySelectorAll(".interactive-item-container"); + if (items.length < 2) return ""; + const lastItem = items[items.length - 1]; + const text = lastItem.textContent || ""; + if (text.length > 20) return text; + return ""; +})()' 2>&1 | sed 's/^"//;s/"$//') + + if [ -n "$RESPONSE" ]; then + break + fi + + # Progress indicator + if (( i % 10 == 0 )); then + echo " Still waiting... (${i}s)" >&2 + fi +done + +$AB screenshot "$SCREENSHOT_DIR/04-response.png" 2>/dev/null || true + +if [ -z "$RESPONSE" ]; then + echo "WARNING: No response received within ${RESPONSE_TIMEOUT}s" >&2 + echo "Screenshots saved to: $SCREENSHOT_DIR" >&2 + exit 1 +fi + +echo "=== Response ===" >&2 +echo "$RESPONSE" + +echo "" >&2 +echo "Screenshots saved to: $SCREENSHOT_DIR" >&2 +echo "Remote agent host test completed successfully." >&2 diff --git a/src/vs/platform/assignment/common/assignment.ts b/src/vs/platform/assignment/common/assignment.ts index 293eaee739ac1..584bef3b1fdf5 100644 --- a/src/vs/platform/assignment/common/assignment.ts +++ b/src/vs/platform/assignment/common/assignment.ts @@ -177,3 +177,10 @@ export class AssignmentFilterProvider implements IExperimentationFilterProvider return filters; } } + +export function getInternalOrg(organisations: string[] | undefined): 'vscode' | 'github' | 'microsoft' | undefined { + const isVSCodeInternal = organisations?.includes('Visual-Studio-Code'); + const isGitHubInternal = organisations?.includes('github'); + const isMicrosoftInternal = organisations?.includes('microsoft') || organisations?.includes('ms-copilot') || organisations?.includes('MicrosoftCopilot'); + return isVSCodeInternal ? 'vscode' : isGitHubInternal ? 'github' : isMicrosoftInternal ? 'microsoft' : undefined; +} diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 2973d9db93918..7e8d1beac191c 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -56,6 +56,8 @@ export interface INativeBrowserElementsService { getElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; + getFocusedElementData(rect: IRectangle, token: CancellationToken, locator: IBrowserTargetLocator, cancellationId?: number): Promise; + startDebugSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; startConsoleSession(token: CancellationToken, locator: IBrowserTargetLocator, cancelAndDetachId?: number): Promise; @@ -79,3 +81,122 @@ export function getDisplayNameFromOuterHTML(outerHTML: string): string { const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; return `${tagName}${id}${className}`; } + +/** + * Format an array of element ancestors into a CSS-selector-like path string. + */ +export function formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { + if (!ancestors || ancestors.length === 0) { + return undefined; + } + + return ancestors + .map(ancestor => { + const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; + const id = ancestor.id ? `#${ancestor.id}` : ''; + return `${ancestor.tagName}${id}${classes}`; + }) + .join(' > '); +} + +/** + * Collapse margin-top/right/bottom/left or padding-top/right/bottom/left + * into a single shorthand value, removing the individual entries from the map. + */ +function createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { + const topKey = `${propertyName}-top`; + const rightKey = `${propertyName}-right`; + const bottomKey = `${propertyName}-bottom`; + const leftKey = `${propertyName}-left`; + + const top = entries.get(topKey); + const right = entries.get(rightKey); + const bottom = entries.get(bottomKey); + const left = entries.get(leftKey); + + if (top === undefined || right === undefined || bottom === undefined || left === undefined) { + return undefined; + } + + entries.delete(topKey); + entries.delete(rightKey); + entries.delete(bottomKey); + entries.delete(leftKey); + + return `${top} ${right} ${bottom} ${left}`; +} + +/** + * Format a key-value record into a markdown-style list, + * collapsing margin/padding into shorthand values. + */ +export function formatElementMap(entries: Readonly> | undefined): string | undefined { + if (!entries || Object.keys(entries).length === 0) { + return undefined; + } + + const normalizedEntries = new Map(Object.entries(entries)); + const lines: string[] = []; + + const marginShorthand = createBoxShorthand(normalizedEntries, 'margin'); + if (marginShorthand) { + lines.push(`- margin: ${marginShorthand}`); + } + + const paddingShorthand = createBoxShorthand(normalizedEntries, 'padding'); + if (paddingShorthand) { + lines.push(`- padding: ${paddingShorthand}`); + } + + for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`- ${name}: ${value}`); + } + + return lines.join('\n'); +} + +/** + * Build a structured text representation of element data for use as chat context. + */ +export function createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { + const sections: string[] = []; + sections.push('Attached Element Context from Integrated Browser'); + sections.push(`Element: ${displayName}`); + + const htmlPath = formatElementPath(elementData.ancestors); + if (htmlPath) { + sections.push(`HTML Path:\n${htmlPath}`); + } + + const attributeTable = formatElementMap(elementData.attributes); + if (attributeTable) { + sections.push(`Attributes:\n${attributeTable}`); + } + + if (attachCss) { + const computedStyleTable = formatElementMap(elementData.computedStyles); + if (computedStyleTable) { + sections.push(`Computed Styles:\n${computedStyleTable}`); + } + } + + if (elementData.dimensions) { + const { top, left, width, height } = elementData.dimensions; + sections.push( + `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` + ); + } + + const innerText = elementData.innerText?.trim(); + if (innerText) { + sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); + } + + sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); + + if (attachCss) { + sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); + } + + return sections.join('\n\n'); +} diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index 5e018a6d718a2..4fab807cd8d2a 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -127,7 +127,6 @@ export class NativeBrowserElementsMainService extends Disposable implements INat targetWebContents.on('console-message', onConsoleMessage); targetWebContents.on('destroyed', onTargetDestroyed); windowWebContents.on('ipc-message', onIpcMessage); - token.onCancellationRequested(cleanupListeners); } /** @@ -431,6 +430,121 @@ export class NativeBrowserElementsMainService extends Disposable implements INat }; } + async getFocusedElementData(windowId: number | undefined, rect: IRectangle, _token: CancellationToken, locator: IBrowserTargetLocator, _cancellationId?: number): Promise { + const window = this.windowById(windowId); + if (!window?.win) { + return undefined; + } + + const allWebContents = webContents.getAllWebContents(); + const simpleBrowserWebview = allWebContents.find(webContent => webContent.id === window.id); + if (!simpleBrowserWebview) { + return undefined; + } + + const debuggers = simpleBrowserWebview.debugger; + if (!debuggers.isAttached()) { + debuggers.attach(); + } + + let sessionId: string | undefined; + try { + const targetId = await this.findWebviewTarget(debuggers, locator); + if (!targetId) { + return undefined; + } + + const attach = await debuggers.sendCommand('Target.attachToTarget', { targetId, flatten: true }); + sessionId = attach.sessionId; + await debuggers.sendCommand('Runtime.enable', {}, sessionId); + + const { result } = await debuggers.sendCommand('Runtime.evaluate', { + expression: `(() => { + const el = document.activeElement; + if (!el || el.nodeType !== 1) { + return undefined; + } + const r = el.getBoundingClientRect(); + const attrs = {}; + for (let i = 0; i < el.attributes.length; i++) { + attrs[el.attributes[i].name] = el.attributes[i].value; + } + const ancestors = []; + let n = el; + while (n && n.nodeType === 1) { + const entry = { tagName: n.tagName.toLowerCase() }; + if (n.id) { + entry.id = n.id; + } + if (typeof n.className === 'string' && n.className.trim().length > 0) { + entry.classNames = n.className.trim().split(/\\s+/).filter(Boolean); + } + ancestors.unshift(entry); + n = n.parentElement; + } + const css = getComputedStyle(el); + const computedStyles = {}; + for (let i = 0; i < css.length; i++) { + const name = css[i]; + computedStyles[name] = css.getPropertyValue(name); + } + const text = (el.innerText || '').trim(); + return { + outerHTML: el.outerHTML, + computedStyle: '', + bounds: { x: r.x, y: r.y, width: r.width, height: r.height }, + ancestors, + attributes: attrs, + computedStyles, + dimensions: { top: r.top, left: r.left, width: r.width, height: r.height }, + innerText: text.length > 100 ? text.slice(0, 100) + '\\u2026' : text + }; + })();`, + returnByValue: true + }, sessionId); + + const focusedData = result?.value as NodeDataResponse | undefined; + if (!focusedData) { + return undefined; + } + + const zoomFactor = simpleBrowserWebview.getZoomFactor(); + const absoluteBounds = { + x: rect.x + focusedData.bounds.x, + y: rect.y + focusedData.bounds.y, + width: focusedData.bounds.width, + height: focusedData.bounds.height + }; + + const clippedBounds = { + x: Math.max(absoluteBounds.x, rect.x), + y: Math.max(absoluteBounds.y, rect.y), + width: Math.max(0, Math.min(absoluteBounds.x + absoluteBounds.width, rect.x + rect.width) - Math.max(absoluteBounds.x, rect.x)), + height: Math.max(0, Math.min(absoluteBounds.y + absoluteBounds.height, rect.y + rect.height) - Math.max(absoluteBounds.y, rect.y)) + }; + + return { + outerHTML: focusedData.outerHTML, + computedStyle: focusedData.computedStyle, + bounds: { + x: clippedBounds.x * zoomFactor, + y: clippedBounds.y * zoomFactor, + width: clippedBounds.width * zoomFactor, + height: clippedBounds.height * zoomFactor + }, + ancestors: focusedData.ancestors, + attributes: focusedData.attributes, + computedStyles: focusedData.computedStyles, + dimensions: focusedData.dimensions, + innerText: focusedData.innerText, + }; + } finally { + if (debuggers.isAttached()) { + debuggers.detach(); + } + } + } + async getNodeData(sessionId: string, debuggers: Electron.Debugger, window: BrowserWindow, cancellationId?: number): Promise { return new Promise((resolve, reject) => { const onMessage = async (event: Electron.Event, method: string, params: { backendNodeId: number }) => { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 28161016f83a3..c2f2161f6e000 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,47 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { URI } from '../../../base/common/uri.js'; +import { UriComponents } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; + +const commandPrefix = 'workbench.action.browser'; +export enum BrowserViewCommandId { + // Tab management + Open = `${commandPrefix}.open`, + NewTab = `${commandPrefix}.newTab`, + QuickOpen = `${commandPrefix}.quickOpen`, + CloseAll = `${commandPrefix}.closeAll`, + CloseAllInGroup = `${commandPrefix}.closeAllInGroup`, + + // Navigation + GoBack = `${commandPrefix}.goBack`, + GoForward = `${commandPrefix}.goForward`, + Reload = `${commandPrefix}.reload`, + HardReload = `${commandPrefix}.hardReload`, + + // Editor actions + FocusUrlInput = `${commandPrefix}.focusUrlInput`, + OpenExternal = `${commandPrefix}.openExternal`, + OpenSettings = `${commandPrefix}.openSettings`, + + // Chat actions + AddElementToChat = `${commandPrefix}.addElementToChat`, + AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + + // Dev Tools + ToggleDevTools = `${commandPrefix}.toggleDevTools`, + + // Storage + ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, + ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, + ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, + + // Find in page + ShowFind = `${commandPrefix}.showFind`, + HideFind = `${commandPrefix}.hideFind`, + FindNext = `${commandPrefix}.findNext`, + FindPrevious = `${commandPrefix}.findPrevious`, +} export interface IBrowserViewBounds { windowId: number; @@ -14,6 +54,7 @@ export interface IBrowserViewBounds { width: number; height: number; zoomFactor: number; + cornerRadius: number; } export interface IBrowserViewCaptureScreenshotOptions { @@ -33,7 +74,9 @@ export interface IBrowserViewState { lastScreenshot: VSBuffer | undefined; lastFavicon: string | undefined; lastError: IBrowserViewLoadError | undefined; + certificateError: IBrowserViewCertificateError | undefined; storageScope: BrowserViewStorageScope; + browserZoomIndex: number; } export interface IBrowserViewNavigationEvent { @@ -41,6 +84,7 @@ export interface IBrowserViewNavigationEvent { title: string; canGoBack: boolean; canGoForward: boolean; + certificateError: IBrowserViewCertificateError | undefined; } export interface IBrowserViewLoadingEvent { @@ -52,6 +96,19 @@ export interface IBrowserViewLoadError { url: string; errorCode: number; errorDescription: string; + certificateError?: IBrowserViewCertificateError; +} + +export interface IBrowserViewCertificateError { + host: string; + fingerprint: string; + error: string; + url: string; + hasTrustedException: boolean; + issuerName: string; + subjectName: string; + validStart: number; + validExpiry: number; } export interface IBrowserViewFocusEvent { @@ -91,7 +148,8 @@ export enum BrowserNewPageLocation { NewWindow = 'newWindow' } export interface IBrowserViewNewPageRequest { - resource: URI; + resource: UriComponents; + url: string; location: BrowserNewPageLocation; // Only applicable if location is NewWindow position?: { x?: number; y?: number; width?: number; height?: number }; @@ -118,6 +176,19 @@ export enum BrowserViewStorageScope { export const ipcBrowserViewChannelName = 'browserView'; +/** + * Discrete zoom levels matching Edge/Chrome. + * Note: When those browsers say "33%" and "67%" zoom, they really mean 33.33...% and 66.66...% + */ +export const browserZoomFactors = [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5] as const; +export const browserZoomDefaultIndex = browserZoomFactors.indexOf(1); +export function browserZoomLabel(zoomFactor: number): string { + return localize('browserZoomPercent', "{0}%", Math.round(zoomFactor * 100)); +} +export function browserZoomAccessibilityLabel(zoomFactor: number): string { + return localize('browserZoomAccessibilityLabel', "Page Zoom: {0}%", Math.round(zoomFactor * 100)); +} + /** * This should match the isolated world ID defined in `preload-browserView.ts`. */ @@ -153,6 +224,14 @@ export interface IBrowserViewService { */ destroyBrowserView(id: string): Promise; + /** + * Get the state of an existing browser view by ID, or throw if it doesn't exist + * @param id The browser view identifier + * @return The state of the browser view for the given ID + * @throws If no browser view exists for the given ID + */ + getState(id: string): Promise; + /** * Update the bounds of a browser view * @param id The browser view identifier @@ -195,8 +274,9 @@ export interface IBrowserViewService { /** * Reload the current page * @param id The browser view identifier + * @param hard Whether to do a hard reload (bypassing cache) */ - reload(id: string): Promise; + reload(id: string, hard?: boolean): Promise; /** * Toggle developer tools for the browser view. @@ -224,13 +304,6 @@ export interface IBrowserViewService { */ captureScreenshot(id: string, options?: IBrowserViewCaptureScreenshotOptions): Promise; - /** - * Dispatch a key event to the browser view - * @param id The browser view identifier - * @param keyEvent The key event data - */ - dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise; - /** * Focus the browser view * @param id The browser view identifier @@ -276,4 +349,31 @@ export interface IBrowserViewService { * @param id The browser view identifier */ clearStorage(id: string): Promise; + + /** Set the browser zoom index (independent from VS Code zoom). */ + setBrowserZoomIndex(id: string, zoomIndex: number): Promise; + + /** + * Trust a certificate for a given host in the browser view's session. + * The page will be automatically reloaded after trusting. + * @param id The browser view identifier + * @param host The hostname that presented the certificate + * @param fingerprint The SHA-256 fingerprint of the certificate to trust + */ + trustCertificate(id: string, host: string, fingerprint: string): Promise; + + /** + * Revoke trust for a previously trusted certificate. + * The browser view will be automatically closed after revoking. + * @param id The browser view identifier + * @param host The hostname to revoke the certificate for + * @param fingerprint The SHA-256 fingerprint of the certificate to revoke + */ + untrustCertificate(id: string, host: string, fingerprint: string): Promise; + + /** + * Update the keybinding accelerators used in browser view context menus. + * @param keybindings A map of command ID to accelerator label + */ + updateKeybindings(keybindings: { [commandId: string]: string }): Promise; } diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts index 0f43b98c8b080..0851ac7ffe4ab 100644 --- a/src/vs/platform/browserView/common/browserViewGroup.ts +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -5,6 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import { CDPEvent, CDPRequest, CDPResponse } from './cdp/types.js'; export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; @@ -27,10 +28,11 @@ export interface IBrowserViewGroup extends IDisposable { readonly onDidAddView: Event; readonly onDidRemoveView: Event; readonly onDidDestroy: Event; + readonly onCDPMessage: Event; addView(viewId: string): Promise; removeView(viewId: string): Promise; - getDebugWebSocketEndpoint(): Promise; + sendCDPMessage(msg: CDPRequest): Promise; } /** @@ -48,12 +50,14 @@ export interface IBrowserViewGroupService { onDynamicDidAddView(groupId: string): Event; onDynamicDidRemoveView(groupId: string): Event; onDynamicDidDestroy(groupId: string): Event; + onDynamicCDPMessage(groupId: string): Event; /** * Create a new browser view group. + * @param windowId The ID of the primary window the group should be associated with. * @returns The id of the newly created group. */ - createGroup(): Promise; + createGroup(windowId: number): Promise; /** * Destroy a browser view group. @@ -78,9 +82,9 @@ export interface IBrowserViewGroupService { removeViewFromGroup(groupId: string, viewId: string): Promise; /** - * Get a short-lived CDP WebSocket endpoint URL for a specific group. - * The returned URL contains a single-use token. + * Send a CDP message to a group's browser proxy. * @param groupId The group identifier. + * @param message The CDP request. */ - getDebugWebSocketEndpoint(groupId: string): Promise; + sendCDPMessage(groupId: string, message: CDPRequest): Promise; } diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts index f261d0261a20b..0f5037b877b0c 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -9,12 +9,18 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; export type IntegratedBrowserOpenSource = /** Created via CDP, such as by the agent using Playwright tools. */ | 'cdpCreated' + /** Opened via a (non-agentic) chat tool invocation. */ + | 'chatTool' /** Opened via the "Open Integrated Browser" command without a URL argument. * This typically means the user ran the command manually from the Command Palette. */ | 'commandWithoutUrl' /** Opened via the "Open Integrated Browser" command with a URL argument. * This typically means another extension or component invoked the command programmatically. */ | 'commandWithUrl' + /** Opened via the quick open feature with no initial URL. */ + | 'quickOpenWithoutUrl' + /** Opened via the quick open feature with an initial URL. */ + | 'quickOpenWithUrl' /** Opened via the "New Tab" command from an existing tab. */ | 'newTabCommand' /** Opened via the localhost link opener when the `workbench.browser.openLocalhostLinks` setting diff --git a/src/vs/platform/browserView/common/browserViewUri.ts b/src/vs/platform/browserView/common/browserViewUri.ts index 66ec58bd5d059..aad379a49aac4 100644 --- a/src/vs/platform/browserView/common/browserViewUri.ts +++ b/src/vs/platform/browserView/common/browserViewUri.ts @@ -5,7 +5,6 @@ import { Schemas } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; -import { generateUuid } from '../../../base/common/uuid.js'; /** * Helper for creating and parsing browser view URIs. @@ -15,22 +14,16 @@ export namespace BrowserViewUri { export const scheme = Schemas.vscodeBrowser; /** - * Creates a resource URI for a browser view with the given URL. - * Optionally accepts an ID; if not provided, a new UUID is generated. + * Creates a resource URI for a browser view with the given ID. */ - export function forUrl(url: string | undefined, id?: string): URI { - const viewId = id ?? generateUuid(); - return URI.from({ - scheme, - path: `/${viewId}`, - query: url ? `url=${encodeURIComponent(url)}` : undefined - }); + export function forId(id: string): URI { + return URI.from({ scheme, path: `/${id}` }); } /** - * Parses a browser view resource URI to extract the ID and URL. + * Parses a browser view resource URI to extract the ID. */ - export function parse(resource: URI): { id: string; url: string } | undefined { + export function parse(resource: URI): { id: string } | undefined { if (resource.scheme !== scheme) { return undefined; } @@ -41,9 +34,7 @@ export namespace BrowserViewUri { return undefined; } - const url = resource.query ? new URLSearchParams(resource.query).get('url') ?? '' : ''; - - return { id, url }; + return { id }; } /** @@ -52,11 +43,4 @@ export namespace BrowserViewUri { export function getId(resource: URI): string | undefined { return parse(resource)?.id; } - - /** - * Extracts the URL from a browser view resource URI. - */ - export function getUrl(resource: URI): string | undefined { - return parse(resource)?.url; - } } diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index 85dc5f6d52d84..86b3f4af1a50d 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ICDPTarget, CDPEvent, CDPError, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; +import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; /** * CDP protocol handler for browser-level connections. @@ -95,22 +95,29 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { for (const target of this.browserTarget.getTargets()) { void this._targets.register(target); } + + // Mirror typed events to the onMessage channel + this._register(this._onEvent.event(event => { + this._onMessage.fire(event); + })); } // #region Public API - // Events to external client (ICDPConnection) + // Events to external clients private readonly _onEvent = this._register(new Emitter()); readonly onEvent: Event = this._onEvent.event; private readonly _onClose = this._register(new Emitter()); readonly onClose: Event = this._onClose.event; + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage: Event = this._onMessage.event; /** - * Send a CDP message and await the result. + * Send a CDP command and await the result. * Browser-level handlers (Browser.*, Target.*) are checked first. * Other commands are routed to the page session identified by sessionId. */ - async sendMessage(method: string, params: unknown = {}, sessionId?: string): Promise { + async sendCommand(method: string, params: unknown = {}, sessionId?: string): Promise { try { // Browser-level command handling if ( @@ -131,7 +138,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { throw new CDPServerError(`Session not found: ${sessionId}`); } - const result = await connection.sendMessage(method, params); + const result = await connection.sendCommand(method, params); return result ?? {}; } catch (error) { if (error instanceof CDPError) { @@ -141,6 +148,27 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } } + /** + * Accept a CDP request from a message-based transport (WebSocket, IPC, etc.), route it, + * and deliver the response or error via {@link onMessage}. + */ + async sendMessage({ id, method, params, sessionId }: CDPRequest): Promise { + return this.sendCommand(method, params, sessionId) + .then(result => { + this._onMessage.fire({ id, result, sessionId }); + }) + .catch((error: Error) => { + this._onMessage.fire({ + id, + error: { + code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, + message: error.message || 'Unknown error' + }, + sessionId + }); + }); + } + // #endregion // #region CDP Commands @@ -206,7 +234,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } private async handleTargetGetTargets() { - return { targetInfos: this._targets.getAllInfos() }; + return { targetInfos: Array.from(this._targets.getAllInfos()) }; } private async handleTargetGetTargetInfo({ targetId }: { targetId?: string } = {}) { diff --git a/src/vs/platform/browserView/common/cdp/types.ts b/src/vs/platform/browserView/common/cdp/types.ts index 603467e3ed2df..6fbfd30e26f9b 100644 --- a/src/vs/platform/browserView/common/cdp/types.ts +++ b/src/vs/platform/browserView/common/cdp/types.ts @@ -151,7 +151,7 @@ export interface ICDPBrowserTarget extends ICDPTarget { /** Get all available targets */ getTargets(): IterableIterator; /** Create a new target in the specified browser context */ - createTarget(url: string, browserContextId?: string): Promise; + createTarget(url: string, browserContextId?: string, windowId?: number): Promise; /** Activate a target (bring to foreground) */ activateTarget(target: ICDPTarget): Promise; /** Close a target */ @@ -187,5 +187,5 @@ export interface ICDPConnection extends IDisposable { * @param sessionId Optional session ID for targeting a specific session * @returns Promise resolving to the result or rejecting with a CDPError */ - sendMessage(method: string, params?: unknown, sessionId?: string): Promise; + sendCommand(method: string, params?: unknown, sessionId?: string): Promise; } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index b49c50b5fb388..1615924a5cfe6 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); @@ -63,23 +62,24 @@ export interface IPlaywrightService { getSummary(pageId: string): Promise; /** - * Run a function with access to a Playwright page. + * Run a function with access to a Playwright page and return its raw result, or throw an error. * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. * @param pageId The browser view ID identifying the page to operate on. * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. * @param args Additional arguments to pass to the function after the `page` object. - * @returns The result of the function execution, including a page summary. + * @returns The result of the function execution. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise; /** - * Takes a screenshot of the current page viewport and returns it as a VSBuffer. - * @param pageId The browser view ID identifying the page to capture. - * @param selector Optional Playwright selector to capture a specific element instead of the viewport. - * @param fullPage Whether to capture the full scrollable page instead of just the viewport. - * @returns The screenshot image data. + * Run a function with access to a Playwright page and return a result for tool output, including error handling. + * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * @param pageId The browser view ID identifying the page to operate on. + * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. + * @param args Additional arguments to pass to the function after the `page` object. + * @returns The result of the function execution, including a page summary. */ - captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise; + invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; /** * Responds to a file chooser dialog on the given page. diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 29832f220ff95..340de08af7df9 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -17,7 +17,7 @@ */ (function () { - const { contextBridge } = require('electron'); + const { contextBridge, ipcRenderer } = require('electron'); // ####################################################################### // ### ### @@ -26,6 +26,37 @@ // ### (https://github.com/electron/electron/issues/25516) ### // ### ### // ####################################################################### + + // Listen for keydown events that the page did not handle and forward them for shortcut handling. + window.addEventListener('keydown', (event) => { + // Require that the event is trusted -- i.e. user-initiated. + // eslint-disable-next-line no-restricted-syntax + if (!(event instanceof KeyboardEvent) || !event.isTrusted) { + return; + } + + // If the event was already handled by the page, do not forward it. + if (event.defaultPrevented) { + return; + } + + // filter to events that either have modifiers or do not have a character representation. + if (!(event.ctrlKey || event.altKey || event.metaKey) && event.key.length === 1) { + return; + } + + ipcRenderer.send('vscode:browserView:keydown', { + key: event.key, + keyCode: event.keyCode, + code: event.code, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + repeat: event.repeat + }); + }); + const globals = { /** * Get the currently selected text in the page. diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 04e7014318870..dc23d2ee0c0a9 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -4,16 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { session } from 'electron'; -import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { BrowserViewStorageScope } from '../common/browserView.js'; +import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; +import { FileAccess } from '../../../base/common/network.js'; -// Same as webviews +// Same as webviews, minus clipboard-read const allowedPermissions = new Set([ 'pointerLock', 'notifications', - 'clipboard-read', 'clipboard-sanitized-write' ]); @@ -32,19 +33,37 @@ const allowedPermissions = new Set([ * an internal registry of live sessions. Use the static methods to * obtain instances. */ -export class BrowserSession extends Disposable { +export class BrowserSession { // #region Static registry /** - * All live sessions keyed by their unique id. + * Primary store — keyed by Electron session so entries are + * automatically removed when the Electron session is GC'd. + * + * The goal is to ensure that BrowserSessions have the exact same lifespan as their Electron sessions. + */ + private static readonly _bySession = new WeakMap(); + + /** + * String-keyed lookup for {@link get} and {@link getBrowserContextIds}. + * Values are weak references so they don't prevent GC of the + * {@link BrowserSession} (and transitively the Electron session). * * ID derivation rules (one-to-one with Electron sessions): * - Global scope -> `"global"` * - Workspace scope -> `"workspace:${workspaceId}"` * - Ephemeral scope -> `"ephemeral:${viewId}"` or `"${type}:${viewId}"` for custom types */ - private static readonly _sessions = new Map(); + private static readonly _byId = new Map>(); + + /** + * Cleans up stale {@link _byId} entries when the Electron session + * they point to is garbage-collected. + */ + private static readonly _finalizer = new FinalizationRegistry((id) => { + BrowserSession._byId.delete(id); + }); /** * Weak set mirroring the Electron sessions owned by any BrowserSession. @@ -65,38 +84,49 @@ export class BrowserSession extends Disposable { * Return an existing session for the given id, or `undefined`. */ static get(id: string): BrowserSession | undefined { - return BrowserSession._sessions.get(id); + const ref = BrowserSession._byId.get(id); + if (!ref) { + return undefined; + } + const bs = ref.deref(); + if (!bs) { + BrowserSession._byId.delete(id); + } + return bs; } /** * Return all live browser context IDs (i.e. all session {@link id}s). */ static getBrowserContextIds(): string[] { - return [...BrowserSession._sessions.keys()]; + const ids: string[] = []; + for (const [id, ref] of BrowserSession._byId) { + if (ref.deref()) { + ids.push(id); + } else { + BrowserSession._byId.delete(id); + } + } + return ids; } /** * Get or create the singleton global-scope session. */ static getOrCreateGlobal(): BrowserSession { - const existing = BrowserSession._sessions.get('global'); - if (existing) { - return existing; - } - return new BrowserSession('global', session.fromPartition('persist:vscode-browser'), BrowserViewStorageScope.Global); + const electronSession = session.fromPartition('persist:vscode-browser'); + return BrowserSession._bySession.get(electronSession) + ?? new BrowserSession('global', electronSession, BrowserViewStorageScope.Global); } /** * Get or create a workspace-scope session for the given workspace. */ static getOrCreateWorkspace(workspaceId: string, workspaceStorageHome: URI): BrowserSession { - const sessionId = `workspace:${workspaceId}`; - const existing = BrowserSession._sessions.get(sessionId); - if (existing) { - return existing; - } const storage = joinPath(workspaceStorageHome, workspaceId, 'browserStorage'); - return new BrowserSession(sessionId, session.fromPath(storage.fsPath), BrowserViewStorageScope.Workspace); + const electronSession = session.fromPath(storage.fsPath); + return BrowserSession._bySession.get(electronSession) + ?? new BrowserSession(`workspace:${workspaceId}`, electronSession, BrowserViewStorageScope.Workspace); } /** @@ -108,11 +138,9 @@ export class BrowserSession extends Disposable { } const sessionId = `${type ?? 'ephemeral'}:${viewId}`; - const existing = BrowserSession._sessions.get(sessionId); - if (existing) { - return existing; - } - return new BrowserSession(sessionId, session.fromPartition(`vscode-browser-${type}${viewId}`), BrowserViewStorageScope.Ephemeral); + const electronSession = session.fromPartition(`vscode-browser-${type}${viewId}`); + return BrowserSession._bySession.get(electronSession) + ?? new BrowserSession(sessionId, electronSession, BrowserViewStorageScope.Ephemeral); } /** @@ -153,9 +181,7 @@ export class BrowserSession extends Disposable { // #region Instance - // Reference count how many browser views are currently using this session. - // When the count drops to zero, the session is removed from the registry. - private refs = 0; + private readonly _trust: BrowserSessionTrust; private constructor( /** @@ -169,46 +195,51 @@ export class BrowserSession extends Disposable { /** Resolved storage scope. */ readonly storageScope: BrowserViewStorageScope, ) { - super(); + this._trust = new BrowserSessionTrust(this); + this.configure(); + BrowserSession.knownSessions.add(electronSession); + BrowserSession._bySession.set(electronSession, this); + BrowserSession._byId.set(id, new WeakRef(this)); + BrowserSession._finalizer.register(electronSession, id); + } - if (BrowserSession._sessions.has(id)) { - throw new Error(`BrowserSession with id '${id}' already exists`); - } + /** Public trust interface for consumers that need cert operations. */ + get trust(): IBrowserSessionTrust { + return this._trust; + } - this.configureSession(); - BrowserSession.knownSessions.add(electronSession); - BrowserSession._sessions.set(id, this); + /** + * Connect application storage to this session so that preferences + * (trusted certificates, permissions, etc.) are persisted across + * restarts. Restores any previously-saved data on first call; + * subsequent calls are no-ops. + */ + connectStorage(storage: IApplicationStorageMainService): void { + this._trust.connectStorage(storage); } /** - * Apply the standard permission policy to the session. + * Apply the permission policy and preload scripts to the session. */ - private configureSession(): void { + private configure(): void { this.electronSession.setPermissionRequestHandler((_webContents, permission, callback) => { return callback(allowedPermissions.has(permission)); }); this.electronSession.setPermissionCheckHandler((_webContents, permission, _origin) => { return allowedPermissions.has(permission); }); - } - - public acquire(): IDisposable { - this.refs++; - return toDisposable(() => { - this.refs--; - if (this.refs === 0) { - this.dispose(); - } + this.electronSession.registerPreloadScript({ + type: 'frame', + filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath }); } - override dispose(): void { - if (this.refs > 0) { - throw new Error(`Cannot dispose BrowserSession because it is still in use`); - } - - BrowserSession._sessions.delete(this.id); - super.dispose(); + /** + * Clear all session data including trust state and all browsing data. + */ + async clearData(): Promise { + await this._trust.clear(); + await this.electronSession.clearData(); } // #endregion diff --git a/src/vs/platform/browserView/electron-main/browserSessionTrust.ts b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts new file mode 100644 index 0000000000000..2e915e7b2f9ed --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserSessionTrust.ts @@ -0,0 +1,317 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; +import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; +import { IBrowserViewCertificateError } from '../common/browserView.js'; +import type { BrowserSession } from './browserSession.js'; + +/** Key used to store trusted certificate data in the application storage. */ +const STORAGE_KEY = 'browserView.sessionTrustData'; + +/** Trust entries expire after 1 week. */ +const TRUST_DURATION_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Shape of the JSON blob persisted under {@link STORAGE_KEY}. + * Top-level keys are session ids; each value holds the session's + * trusted certificates. + */ +interface PersistedTrustData { + [sessionId: string]: { + trustedCerts?: { host: string; fingerprint: string; expiresAt: number }[]; + }; +} + +/** + * Public subset of {@link BrowserSessionTrust} exposed to consumers + * (e.g. {@link BrowserView}) that need to trust/untrust certificates + * or query certificate errors. + */ +export interface IBrowserSessionTrust { + trustCertificate(host: string, fingerprint: string): Promise; + untrustCertificate(host: string, fingerprint: string): Promise; + getCertificateError(url: string): IBrowserViewCertificateError | undefined; + installCertErrorHandler(webContents: Electron.WebContents): void; +} + +/** + * Centralises all certificate and trust-related security logic for a + * browser session. Owns the trusted-certificate store, the cert-error + * cache, the `setCertificateVerifyProc` handler on the Electron session, + * and the per-`WebContents` `certificate-error` handler. + */ +export class BrowserSessionTrust implements IBrowserSessionTrust { + + /** + * Trusted certificates stored as host → (fingerprint → expiration epoch ms). + * Entries are time-limited; see {@link TRUST_DURATION_MS}. + */ + private readonly _trustedCertificates = new Map>(); + + /** + * Last known certificate per host (hostname → { fingerprint, error }). + * Populated by `setCertificateVerifyProc` which fires for every TLS + * handshake, not just errors. This lets us look up cert status for a + * URL even after Chromium has cached the allow decision. + */ + private readonly _certErrors = new Map(); + + /** + * Application storage service for persisting trusted certificates + * across restarts. Set via {@link connectStorage}; `undefined` until then. + */ + private _storage: IApplicationStorageMainService | undefined; + + constructor( + private readonly _session: BrowserSession, + ) { + this._installCertVerifyProc(); + } + + /** + * Install the session-level certificate verification callback that records cert errors. + * This does not grant any trust by itself; it just populates the `_certErrors` cache. + */ + private _installCertVerifyProc(): void { + this._session.electronSession.setCertificateVerifyProc((request, callback) => { + const { hostname, errorCode, certificate, verificationResult } = request; + + if (errorCode !== 0) { + this._certErrors.set(hostname, { certificate, error: verificationResult }); + } else { + this._certErrors.delete(hostname); + } + + return callback(-3); // Always use default handling from Chromium + }); + } + + /** + * Install a `certificate-error` handler on a {@link Electron.WebContents} + * so that user-trusted certificates are accepted at the page level. + */ + installCertErrorHandler(webContents: Electron.WebContents): void { + webContents.on('certificate-error', (event, url, _error, certificate, callback) => { + event.preventDefault(); + + const host = URL.parse(url)?.hostname; + if (!host) { + return callback(false); + } + + if (this.isCertificateTrusted(host, certificate.fingerprint)) { + return callback(true); + } + + return callback(false); + }); + } + + /** + * Look up the certificate status for a URL by extracting the host and + * checking whether we have a last-known bad cert that was user-trusted. + * Returns the cert error info if the host has a bad cert that was trusted, + * or `undefined` if the cert is valid or unknown. + */ + getCertificateError(url: string): IBrowserViewCertificateError | undefined { + const parsed = URL.parse(url); + if (!parsed || parsed.protocol !== 'https:') { + return undefined; + } + + const host = parsed.hostname; + if (!host) { + return undefined; + } + + const known = this._certErrors.get(host); + if (!known) { + return undefined; + } + + const cert = known.certificate; + return { + host, + fingerprint: cert.fingerprint, + error: known.error, + url, + hasTrustedException: this.isCertificateTrusted(host, cert.fingerprint), + issuerName: cert.issuerName, + subjectName: cert.subjectName, + validStart: cert.validStart, + validExpiry: cert.validExpiry, + }; + } + + /** + * Trust a certificate identified by host and SHA-256 fingerprint. + */ + async trustCertificate(host: string, fingerprint: string): Promise { + let entries = this._trustedCertificates.get(host); + if (!entries) { + entries = new Map(); + this._trustedCertificates.set(host, entries); + } + entries.set(fingerprint, Date.now() + TRUST_DURATION_MS); + this.writeStorage(); + } + + /** + * Revoke trust for a certificate identified by host and fingerprint. + */ + async untrustCertificate(host: string, fingerprint: string): Promise { + const entries = this._trustedCertificates.get(host); + if (entries && entries.delete(fingerprint)) { + if (entries.size === 0) { + this._trustedCertificates.delete(host); + } + } else { + throw new Error(`Certificate not found: host=${host} fingerprint=${fingerprint}`); + } + this.writeStorage(); + // Important: close all connections since they may be using the now-untrusted cert. + await this._session.electronSession.closeAllConnections(); + } + + /** + * Check whether a certificate is trusted for a given host. + */ + isCertificateTrusted(host: string, fingerprint: string): boolean { + const expiresAt = this._trustedCertificates.get(host)?.get(fingerprint); + if (expiresAt === undefined) { + return false; + } + if (Date.now() > expiresAt) { + return false; + } + return true; + } + + /** + * Connect application storage so that trusted certificates are + * persisted across restarts. Restores any previously-saved data on + * first call; subsequent calls are no-ops. + */ + connectStorage(storage: IApplicationStorageMainService): void { + if (this._storage) { + return; // already connected + } + this._storage = storage; + this.readStorage(); + } + + /** + * Clear all trust state: in-memory certs, cert-error cache, persisted + * data, and close open connections that may be using now-untrusted certs. + */ + async clear(): Promise { + this._trustedCertificates.clear(); + this._certErrors.clear(); + this.writeStorage(); + // Important: close all connections since they may be using now-untrusted certs. + await this._session.electronSession.closeAllConnections(); + } + + // #region Persistence helpers + + /** + * Restore trusted certificates from application storage. + */ + private readStorage(): void { + const storage = this._storage; + if (!storage) { + return; + } + + const raw = storage.get(STORAGE_KEY, StorageScope.APPLICATION); + if (!raw) { + return; + } + + const now = Date.now(); + let pruned = false; + try { + const all: PersistedTrustData = JSON.parse(raw); + const certs = all[this._session.id]?.trustedCerts; + if (certs) { + for (const { host, fingerprint, expiresAt } of certs) { + if (expiresAt > now) { + let entries = this._trustedCertificates.get(host); + if (!entries) { + entries = new Map(); + this._trustedCertificates.set(host, entries); + } + entries.set(fingerprint, expiresAt); + } else { + pruned = true; + } + } + } + } catch { + // Corrupt data — ignore + } + + // Flush expired entries from storage + if (pruned) { + this.writeStorage(); + } + } + + /** + * Write trusted certificates to application storage. + * The single storage key holds **all** sessions' data so that we can + * clean up stale entries atomically. + */ + private writeStorage(): void { + const storage = this._storage; + if (!storage) { + return; + } + + // Read existing blob (other sessions may have data too) + let all: PersistedTrustData = {}; + try { + const raw = storage.get(STORAGE_KEY, StorageScope.APPLICATION); + if (raw) { + all = JSON.parse(raw); + } + } catch { + // Overwrite corrupt data + } + + // Ensure this session's entry exists + if (!all[this._session.id]) { + all[this._session.id] = {}; + } + + // Update the trusted certs slice + if (this._trustedCertificates.size === 0) { + delete all[this._session.id].trustedCerts; + } else { + const certs: { host: string; fingerprint: string; expiresAt: number }[] = []; + for (const [host, entries] of this._trustedCertificates) { + for (const [fingerprint, expiresAt] of entries) { + certs.push({ host, fingerprint, expiresAt }); + } + } + all[this._session.id].trustedCerts = certs; + } + + // Remove empty session entries + if (Object.keys(all[this._session.id]).length === 0) { + delete all[this._session.id]; + } + + // Write back (or remove if empty) + if (Object.keys(all).length === 0) { + storage.remove(STORAGE_KEY, StorageScope.APPLICATION); + } else { + storage.store(STORAGE_KEY, JSON.stringify(all), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + // #endregion +} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 45e5d838d277c..a38625fa6163b 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -4,34 +4,21 @@ *--------------------------------------------------------------------------------------------*/ import { WebContentsView, webContents } from 'electron'; -import { FileAccess } from '../../../base/common/network.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js'; -import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex } from '../common/browserView.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; -import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; +import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; -import { isMacintosh } from '../../../base/common/platform.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { ICDPTarget, ICDPConnection, CDPTargetInfo } from '../common/cdp/types.js'; import { BrowserSession } from './browserSession.js'; - -/** Key combinations that are used in system-level shortcuts. */ -const nativeShortcuts = new Set([ - KeyMod.CtrlCmd | KeyCode.KeyA, - KeyMod.CtrlCmd | KeyCode.KeyC, - KeyMod.CtrlCmd | KeyCode.KeyV, - KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV, - KeyMod.CtrlCmd | KeyCode.KeyX, - ...(isMacintosh ? [] : [KeyMod.CtrlCmd | KeyCode.KeyY]), - KeyMod.CtrlCmd | KeyCode.KeyZ, - KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ -]); +import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; +import { hasKey } from '../../../base/common/types.js'; +import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; /** * Represents a single browser view instance with its WebContentsView and all associated logic. @@ -45,10 +32,10 @@ export class BrowserView extends Disposable implements ICDPTarget { private _lastFavicon: string | undefined = undefined; private _lastError: IBrowserViewLoadError | undefined = undefined; private _lastUserGestureTimestamp: number = -Infinity; + private _browserZoomIndex: number = browserZoomDefaultIndex; private _debugger: BrowserViewDebugger; - private _window: IBaseWindow | undefined; - private _isSendingKeyEvent = false; + private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; private readonly _onDidNavigate = this._register(new Emitter()); @@ -88,6 +75,7 @@ export class BrowserView extends Disposable implements ICDPTarget { public readonly id: string, public readonly session: BrowserSession, createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, @@ -103,7 +91,6 @@ export class BrowserView extends Disposable implements ICDPTarget { sandbox: true, webviewTag: false, session: this.session.electronSession, - preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath, // TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed type: 'browserView' @@ -135,11 +122,12 @@ export class BrowserView extends Disposable implements ICDPTarget { action: 'allow', createWindow: (options) => { const childView = createChildView(options); - const resource = BrowserViewUri.forUrl(details.url, childView.id); + const resource = BrowserViewUri.forId(childView.id); // Fire event for the workbench to open this view this._onDidRequestNewPage.fire({ resource, + url: details.url, location, position: { x: options.x, y: options.y, width: options.width, height: options.height } }); @@ -150,14 +138,16 @@ export class BrowserView extends Disposable implements ICDPTarget { }; }); + this._view.webContents.on('context-menu', (_event, params) => { + openContextMenu(this, params); + }); + this._view.webContents.on('destroyed', () => { this.dispose(); }); this._debugger = new BrowserViewDebugger(this, this.logService); - this._register(session.acquire()); - this.setupEventListeners(); } @@ -215,11 +205,13 @@ export class BrowserView extends Disposable implements ICDPTarget { }); const fireNavigationEvent = () => { + const url = webContents.getURL(); this._onDidNavigate.fire({ - url: webContents.getURL(), + url, title: webContents.getTitle(), canGoBack: webContents.navigationHistory.canGoBack(), - canGoForward: webContents.navigationHistory.canGoForward() + canGoForward: webContents.navigationHistory.canGoForward(), + certificateError: this.session.trust.getCertificateError(url) }); }; @@ -244,7 +236,9 @@ export class BrowserView extends Disposable implements ICDPTarget { this._lastError = { url: validatedURL, errorCode, - errorDescription + errorDescription, + // -200 - -220 are the range of certificate errors in Chromium. + certificateError: errorCode <= -200 && errorCode >= -220 ? this.session.trust.getCertificateError(validatedURL) : undefined }; fireLoadingEvent(false); @@ -252,12 +246,15 @@ export class BrowserView extends Disposable implements ICDPTarget { url: validatedURL, title: '', canGoBack: webContents.navigationHistory.canGoBack(), - canGoForward: webContents.navigationHistory.canGoForward() + canGoForward: webContents.navigationHistory.canGoForward(), + certificateError: this.session.trust.getCertificateError(validatedURL) }); } }); webContents.on('did-finish-load', () => fireLoadingEvent(false)); + this.session.trust.installCertErrorHandler(webContents); + webContents.on('render-process-gone', (_event, details) => { this._lastError = { url: webContents.getURL(), @@ -272,6 +269,12 @@ export class BrowserView extends Disposable implements ICDPTarget { webContents.on('did-navigate', fireNavigationEvent); webContents.on('did-navigate-in-page', fireNavigationEvent); + // Chromium resets the zoom factor to its per-origin default (100%) when + // navigating to a new document. Re-apply our stored zoom to override it. + webContents.on('did-navigate', () => { + this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); + }); + // Focus events webContents.on('focus', () => { this._onDidChangeFocus.fire({ focused: true }); @@ -281,13 +284,41 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidChangeFocus.fire({ focused: false }); }); - // Key down events - listen for raw key input events - webContents.on('before-input-event', async (event, input) => { - if (input.type === 'keyDown' && !this._isSendingKeyEvent) { - if (this.tryHandleCommand(input)) { - event.preventDefault(); - } + // Forward key down events that weren't handled by the page to the workbench for shortcut handling. + webContents.ipc.on('vscode:browserView:keydown', (_event, keyEvent: IBrowserViewKeyDownEvent) => { + this._onDidKeyCommand.fire(keyEvent); + }); + // If the page won't be able to handle events, forward key down events directly. + webContents.on('before-input-event', (event, input) => { + if (input.type !== 'keyDown') { + return; + } + + const pageIsAvailable = this._view.getVisible() + && !webContents.isCrashed() + && !this._debugger.isPaused; + if (pageIsAvailable) { + return; + } + + // This logic should mirror that in preload-browserView.ts. + if (!(input.control || input.alt || input.meta) && input.key.length === 1) { + return; } + + event.preventDefault(); + + const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; + this._onDidKeyCommand.fire({ + key: input.key, + keyCode: eventKeyCode, + code: input.code, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + metaKey: input.meta, + repeat: input.isAutoRepeat + }); }); // Track user gestures for popup blocking logic. @@ -347,8 +378,10 @@ export class BrowserView extends Disposable implements ICDPTarget { */ getState(): IBrowserViewState { const webContents = this._view.webContents; + const url = webContents.getURL(); + return { - url: webContents.getURL(), + url, title: webContents.getTitle(), canGoBack: webContents.navigationHistory.canGoBack(), canGoForward: webContents.navigationHistory.canGoForward(), @@ -359,7 +392,9 @@ export class BrowserView extends Disposable implements ICDPTarget { lastScreenshot: this._lastScreenshot, lastFavicon: this._lastFavicon, lastError: this._lastError, - storageScope: this.session.storageScope + certificateError: this.session.trust.getCertificateError(url), + storageScope: this.session.storageScope, + browserZoomIndex: this._browserZoomIndex }; } @@ -375,7 +410,7 @@ export class BrowserView extends Disposable implements ICDPTarget { */ layout(bounds: IBrowserViewBounds): void { if (this._window?.win?.id !== bounds.windowId) { - const newWindow = this.windowById(bounds.windowId); + const newWindow = this._windowById(bounds.windowId); if (newWindow) { this._window?.win?.contentView.removeChildView(this._view); this._window = newWindow; @@ -383,7 +418,7 @@ export class BrowserView extends Disposable implements ICDPTarget { } } - this._view.webContents.setZoomFactor(bounds.zoomFactor); + this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor)); this._view.setBounds({ x: Math.round(bounds.x * bounds.zoomFactor), y: Math.round(bounds.y * bounds.zoomFactor), @@ -392,6 +427,12 @@ export class BrowserView extends Disposable implements ICDPTarget { }); } + setBrowserZoomIndex(zoomIndex: number): void { + this._browserZoomIndex = Math.max(0, Math.min(zoomIndex, browserZoomFactors.length - 1)); + const browserZoomFactor = browserZoomFactors[this._browserZoomIndex]; + this._view.webContents.setZoomFactor(browserZoomFactor); + } + /** * Set the visibility of this view */ @@ -444,8 +485,12 @@ export class BrowserView extends Disposable implements ICDPTarget { /** * Reload the current page */ - reload(): void { - this._view.webContents.reload(); + reload(hard?: boolean): void { + if (hard) { + this._view.webContents.reloadIgnoringCache(); + } else { + this._view.webContents.reload(); + } } /** @@ -468,8 +513,7 @@ export class BrowserView extends Disposable implements ICDPTarget { async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { const quality = options?.quality ?? 80; const image = await this._view.webContents.capturePage(options?.rect, { - stayHidden: true, - stayAwake: true + stayHidden: true }); const buffer = image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); @@ -480,42 +524,6 @@ export class BrowserView extends Disposable implements ICDPTarget { return screenshot; } - /** - * Dispatch a keyboard event to this view - */ - async dispatchKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { - const event: Electron.KeyboardInputEvent = { - type: 'keyDown', - keyCode: keyEvent.key, - modifiers: [] - }; - if (keyEvent.ctrlKey) { - event.modifiers!.push('control'); - } - if (keyEvent.shiftKey) { - event.modifiers!.push('shift'); - } - if (keyEvent.altKey) { - event.modifiers!.push('alt'); - } - if (keyEvent.metaKey) { - event.modifiers!.push('meta'); - } - this._isSendingKeyEvent = true; - try { - await this._view.webContents.sendInputEvent(event); - } finally { - this._isSendingKeyEvent = false; - } - } - - /** - * Set the zoom factor of this view - */ - async setZoomFactor(zoomFactor: number): Promise { - await this._view.webContents.setZoomFactor(zoomFactor); - } - /** * Focus this view */ @@ -566,7 +574,23 @@ export class BrowserView extends Disposable implements ICDPTarget { * Clear all storage data for this browser view's session */ async clearStorage(): Promise { - await this.session.electronSession.clearData(); + await this.session.clearData(); + } + + /** + * Trust a certificate for a given host and reload the page. + */ + async trustCertificate(host: string, fingerprint: string): Promise { + await this.session.trust.trustCertificate(host, fingerprint); + this._view.webContents.reload(); + } + + /** + * Revoke trust for a previously trusted certificate and close the view. + */ + async untrustCertificate(host: string, fingerprint: string): Promise { + await this.session.trust.untrustCertificate(host, fingerprint); + this.dispose(); } /** @@ -576,6 +600,22 @@ export class BrowserView extends Disposable implements ICDPTarget { return this._view; } + /** + * Get the hosting Electron window for this view, if any. + * This can be an auxiliary window, depending on where the view is currently hosted. + */ + getElectronWindow(): Electron.BrowserWindow | undefined { + return this._window?.win ?? undefined; + } + + /** + * Get the main code window hosting this browser view, if any. This is used for routing commands from the browser view to the correct window. + * If the browser view is hosted in an auxiliary window, this will return the parent code window of that auxiliary window. + */ + getTopCodeWindow(): ICodeWindow | undefined { + return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; + } + // ============ ICDPTarget implementation ============ /** @@ -609,65 +649,18 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidClose.fire(); // Clean up the view and all its event listeners - this._view.webContents.close({ waitForBeforeUnload: false }); - - super.dispose(); - } - - /** - * Potentially handle an input event as a VS Code command. - * Returns `true` if the event was forwarded to VS Code and should not be handled natively. - */ - private tryHandleCommand(input: Electron.Input): boolean { - const eventKeyCode = SCAN_CODE_STR_TO_EVENT_KEY_CODE[input.code] || 0; - const keyCode = EVENT_KEY_CODE_MAP[eventKeyCode] || KeyCode.Unknown; - - const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow; - const isNonEditingKey = - keyCode === KeyCode.Escape || - keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || - keyCode >= KeyCode.AudioVolumeMute; - - // Ignore most Alt-only inputs (often used for accented characters or menu accelerators) - const isAltOnlyInput = input.alt && !input.control && !input.meta; - if (isAltOnlyInput && !isNonEditingKey && !isArrowKey) { - return false; - } - - // Only reroute if there's a command modifier or it's a non-editing key - const hasCommandModifier = input.control || input.alt || input.meta; - if (!hasCommandModifier && !isNonEditingKey) { - return false; + if (!this._view.webContents.isDestroyed()) { + this._view.webContents.close({ waitForBeforeUnload: false }); } - // Ignore Ctrl/Cmd + [A,C,V,X,Z] shortcuts to allow native handling (e.g. copy/paste) - const isControlInput = isMacintosh ? input.meta : input.control; - const modifiedKeyCode = keyCode | - (isControlInput ? KeyMod.CtrlCmd : 0) | - (input.shift ? KeyMod.Shift : 0) | - (input.alt ? KeyMod.Alt : 0); - if (nativeShortcuts.has(modifiedKeyCode)) { - return false; - } - - this._onDidKeyCommand.fire({ - key: input.key, - keyCode: eventKeyCode, - code: input.code, - ctrlKey: input.control || false, - shiftKey: input.shift || false, - altKey: input.alt || false, - metaKey: input.meta || false, - repeat: input.isAutoRepeat || false - }); - return true; + super.dispose(); } - private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { - return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId); + private _windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { + return this._codeWindowById(windowId) ?? this._auxiliaryWindowById(windowId); } - private codeWindowById(windowId: number | undefined): ICodeWindow | undefined { + private _codeWindowById(windowId: number | undefined): ICodeWindow | undefined { if (typeof windowId !== 'number') { return undefined; } @@ -675,7 +668,7 @@ export class BrowserView extends Disposable implements ICDPTarget { return this.windowsMainService.getWindowById(windowId); } - private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { + private _auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { if (typeof windowId !== 'number') { return undefined; } diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts deleted file mode 100644 index 30ad512c042d0..0000000000000 --- a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts +++ /dev/null @@ -1,269 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { ILogService } from '../../log/common/log.js'; -import type * as http from 'http'; -import { AddressInfo, Socket } from 'net'; -import { upgradeToISocket } from '../../../base/parts/ipc/node/ipc.net.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { CDPEvent, CDPRequest, CDPError, CDPErrorCode, ICDPBrowserTarget, ICDPConnection } from '../common/cdp/types.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; - -export const IBrowserViewCDPProxyServer = createDecorator('browserViewCDPProxyServer'); - -export interface IBrowserViewCDPProxyServer { - readonly _serviceBrand: undefined; - - /** - * Returns a debug endpoint with a short-lived, single-use token for a specific browser target. - */ - getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise; - - /** - * Unregister a previously registered browser target. - */ - removeTarget(target: ICDPBrowserTarget): Promise; -} - -/** - * WebSocket server that provides CDP debugging for browser views. - * - * Manages a registry of {@link ICDPBrowserTarget} instances, each reachable - * at its own `/devtools/browser/{id}` WebSocket endpoint. - */ -export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer { - declare readonly _serviceBrand: undefined; - - private server: http.Server | undefined; - private port: number | undefined; - - private readonly tokens = this._register(new TokenManager()); - private readonly targets = new Map(); - - constructor( - @ILogService private readonly logService: ILogService - ) { - super(); - } - - /** - * Register a browser target and return a WebSocket endpoint URL for it. - * The target is reachable at `/devtools/browser/{targetId}`. - */ - async getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise { - await this.ensureServerStarted(); - - const targetInfo = await target.getTargetInfo(); - const targetId = targetInfo.targetId; - - // Register (or re-register) the target - this.targets.set(targetId, target); - - const token = await this.tokens.issueToken(targetId); - return `ws://localhost:${this.port}/devtools/browser/${targetId}?token=${token}`; - } - - /** - * Unregister a previously registered browser target. - */ - async removeTarget(target: ICDPBrowserTarget): Promise { - const targetInfo = await target.getTargetInfo(); - this.targets.delete(targetInfo.targetId); - } - - private async ensureServerStarted(): Promise { - if (this.server) { - return; - } - - const http = await import('http'); - this.server = http.createServer(); - - await new Promise((resolve, reject) => { - // Only listen on localhost to prevent external access - this.server!.listen(0, '127.0.0.1', () => resolve()); - this.server!.once('error', reject); - }); - - const address = this.server.address() as AddressInfo; - this.port = address.port; - - this.server.on('request', (req, res) => this.handleHttpRequest(req, res)); - this.server.on('upgrade', (req: http.IncomingMessage, socket: Socket) => this.handleWebSocketUpgrade(req, socket)); - } - - private async handleHttpRequest(_req: http.IncomingMessage, res: http.ServerResponse): Promise { - this.logService.debug(`[BrowserViewDebugProxy] HTTP request at ${_req.url}`); - // No support for HTTP endpoints for now. - res.writeHead(404); - res.end(); - } - - private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void { - const [pathname, params] = (req.url || '').split('?'); - - const browserMatch = pathname.match(/^\/devtools\/browser\/([^/?]+)$/); - - this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`); - - if (!browserMatch) { - this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`); - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.end(); - return; - } - - const targetId = browserMatch[1]; - - const token = new URLSearchParams(params).get('token'); - const tokenTargetId = token && this.tokens.consumeToken(token); - if (!tokenTargetId || tokenTargetId !== targetId) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); - socket.end(); - return; - } - - const target = this.targets.get(targetId); - if (!target) { - this.logService.warn(`[BrowserViewDebugProxy] Browser target not found: ${targetId}`); - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.end(); - return; - } - - this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`); - - const upgraded = upgradeToISocket(req, socket, { - debugLabel: 'browser-view-cdp-' + generateUuid(), - enableMessageSplitting: false, - }); - - if (!upgraded) { - return; - } - - const proxy = new CDPBrowserProxy(target); - const disposables = this.wireWebSocket(upgraded, proxy); - this._register(disposables); - this._register(upgraded); - } - - /** - * Wire a WebSocket (ISocket) to an ICDPConnection bidirectionally. - * Returns a DisposableStore that cleans up all subscriptions. - */ - private wireWebSocket(upgraded: ISocket, connection: ICDPConnection): DisposableStore { - const disposables = new DisposableStore(); - - // Socket -> Connection: parse JSON, call sendMessage, write response/error - disposables.add(upgraded.onData((rawData: VSBuffer) => { - try { - const message = rawData.toString(); - const { id, method, params, sessionId } = JSON.parse(message) as CDPRequest; - this.logService.debug(`[BrowserViewDebugProxy] <- ${message}`); - connection.sendMessage(method, params, sessionId) - .then((result: unknown) => { - const response = { id, result, sessionId }; - const responseStr = JSON.stringify(response); - this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); - upgraded.write(VSBuffer.fromString(responseStr)); - }) - .catch((error: Error) => { - const response = { - id, - error: { - code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, - message: error.message || 'Unknown error' - }, - sessionId - }; - const responseStr = JSON.stringify(response); - this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); - upgraded.write(VSBuffer.fromString(responseStr)); - }); - } catch (error) { - this.logService.error('[BrowserViewDebugProxy] Error parsing message:', error); - upgraded.end(); - } - })); - - // Connection -> Socket: serialize events and write - disposables.add(connection.onEvent((event: CDPEvent) => { - const eventStr = JSON.stringify(event); - this.logService.debug(`[BrowserViewDebugProxy] -> ${eventStr}`); - upgraded.write(VSBuffer.fromString(eventStr)); - })); - - // Connection close -> close socket - disposables.add(connection.onClose(() => { - this.logService.debug(`[BrowserViewDebugProxy] WebSocket closing`); - upgraded.end(); - })); - - // Socket closed -> cleanup - disposables.add(upgraded.onClose(() => { - this.logService.debug(`[BrowserViewDebugProxy] WebSocket closed`); - connection.dispose(); - disposables.dispose(); - })); - - return disposables; - } - - override dispose(): void { - if (this.server) { - this.server.close(); - this.server = undefined; - } - - super.dispose(); - } -} - -class TokenManager extends Disposable { - /** Map of currently valid single-use tokens to their associated details. */ - private readonly tokens = new Map(); - - /** - * Creates a short-lived, single-use token bound to a specific target. - * The token is revoked once consumed or after 30 seconds. - */ - async issueToken(details: TDetails): Promise { - const token = this.makeToken(); - this.tokens.set(token, { details: Object.freeze(details), expiresAt: Date.now() + 30_000 }); - this._register(disposableTimeout(() => this.tokens.delete(token), 30_000)); - return token; - } - - /** - * Consume a token. Returns the details it was issued with, or - * `undefined` if the token is invalid or expired. - */ - consumeToken(token: string): TDetails | undefined { - if (!token) { - return undefined; - } - const info = this.tokens.get(token); - if (!info) { - return undefined; - } - this.tokens.delete(token); - return Date.now() <= info.expiresAt ? info.details : undefined; - } - - private makeToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join(''); - const base64 = btoa(binary); - const urlSafeToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - - return urlSafeToken; - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index e9956c91b185e..ebdbdb3bf3e83 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -20,6 +20,10 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { /** Map from CDP sessionId to the per-connection event emitter */ private readonly _sessions = this._register(new DisposableMap()); + /** Whether any attached debugger session has paused JavaScript execution. */ + private _isPaused = false; + get isPaused(): boolean { return this._isPaused; } + /** * The real CDP targetId discovered from Target.getTargets(). * Ideally this could be fetched synchronously from the WebContents, @@ -60,7 +64,7 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { }) as { sessionId: string }; const sessionId = result.sessionId; - const session = new DebugSession(sessionId, this._electronDebugger); + const session = new DebugSession(sessionId, this.view, this._electronDebugger); this._sessions.set(sessionId, session); session.onClose(() => this._sessions.deleteAndDispose(sessionId)); @@ -141,6 +145,13 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { return; } + // Track debugger pause state + if (method === 'Debugger.paused') { + this._isPaused = true; + } else if (method === 'Debugger.resumed') { + this._isPaused = false; + } + // Find the session for this sessionId and fire the event const session = this._sessions.get(sessionId); if (session) { @@ -182,18 +193,27 @@ class DebugSession extends Disposable implements ICDPConnection { constructor( public readonly sessionId: string, + private readonly _view: BrowserView, private readonly _electronDebugger: Electron.Debugger ) { super(); } - async sendMessage(method: string, params?: unknown, _sessionId?: string): Promise { + async sendCommand(method: string, params?: unknown, _sessionId?: string): Promise { // This crashes Electron. Don't pass it through. if (method === 'Emulation.setDeviceMetricsOverride') { return Promise.resolve({}); } - return this._electronDebugger.sendCommand(method, params, this.sessionId); + const result = await this._electronDebugger.sendCommand(method, params, this.sessionId); + + // Electron overrides dialog behavior in a way that this command does not auto-dismiss the dialog. + // So we manually emit the (internal) event to dismiss open dialogs when this command is sent. + if (method === 'Page.handleJavaScriptDialog') { + this._view.webContents.emit('-cancel-dialogs'); + } + + return result; } override dispose(): void { diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index d7d59c2701889..beed4f6042e9f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -6,10 +6,9 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { BrowserView } from './browserView.js'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; -import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; /** @@ -49,8 +48,8 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I constructor( readonly id: string, - @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, - @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, + private readonly windowId: number, + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService ) { super(); } @@ -127,12 +126,12 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I return this.views.values(); } - async createTarget(url: string, browserContextId?: string): Promise { + async createTarget(url: string, browserContextId?: string, windowId = this.windowId): Promise { if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId); + const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); if (target instanceof BrowserView) { await this.addView(target.id); } @@ -188,19 +187,26 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I // #region CDP endpoint - /** - * Get a WebSocket endpoint URL for connecting to this group's CDP - * session. The URL contains a short-lived, single-use token. - */ - async getDebugWebSocketEndpoint(): Promise { - return this.cdpProxyServer.getWebSocketEndpointForTarget(this); + private _debugger: CDPBrowserProxy | undefined; + get debugger(): CDPBrowserProxy { + if (!this._debugger) { + this._debugger = this._register(new CDPBrowserProxy(this)); + } + return this._debugger; + } + + async sendCDPMessage(msg: CDPRequest): Promise { + return this.debugger.sendMessage(msg); + } + + get onCDPMessage(): Event { + return this.debugger.onMessage; } // #endregion override dispose(): void { this._onDidDestroy.fire(); - this.cdpProxyServer.removeTarget(this); super.dispose(); } } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts index 20dd6331c0ea5..c34bfa16b9d92 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -9,6 +9,7 @@ import { createDecorator, IInstantiationService } from '../../instantiation/comm import { generateUuid } from '../../../base/common/uuid.js'; import { IBrowserViewGroupService, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; import { BrowserViewGroup } from './browserViewGroup.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); @@ -33,9 +34,9 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV super(); } - async createGroup(): Promise { + async createGroup(windowId: number): Promise { const id = generateUuid(); - const group = this.instantiationService.createInstance(BrowserViewGroup, id); + const group = this.instantiationService.createInstance(BrowserViewGroup, id, windowId); this.groups.set(id, group); // Auto-cleanup when the group disposes itself @@ -58,8 +59,8 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).removeView(viewId); } - async getDebugWebSocketEndpoint(groupId: string): Promise { - return this._getGroup(groupId).getDebugWebSocketEndpoint(); + async sendCDPMessage(groupId: string, message: CDPRequest): Promise { + return this._getGroup(groupId).debugger.sendMessage(message); } onDynamicDidAddView(groupId: string): Event { @@ -74,6 +75,10 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).onDidDestroy; } + onDynamicCDPMessage(groupId: string): Event { + return this._getGroup(groupId).debugger.onMessage; + } + /** * Get a group or throw if not found. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 7db0af2ac3451..d7e9ac1737f57 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { clipboard, Menu, MenuItem } from 'electron'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -16,9 +17,15 @@ import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IProductService } from '../../product/common/productService.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { logBrowserOpen } from '../common/browserViewTelemetry.js'; +import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { localize } from '../../../nls.js'; +import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; +import { ITextEditorOptions } from '../../editor/common/editor.js'; +import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -40,6 +47,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); + private _keybindings: { [commandId: string]: string } = Object.create(null); // ICDPBrowserTarget events private readonly _onTargetCreated = this._register(new Emitter()); @@ -53,38 +61,13 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IProductService private readonly productService: IProductService, - @ITelemetryService private readonly telemetryService: ITelemetryService + @ITelemetryService private readonly telemetryService: ITelemetryService, + @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, + @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService ) { super(); } - /** - * Create a browser view backed by the given {@link BrowserSession}. - */ - private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { - if (this.browserViews.has(id)) { - throw new Error(`Browser view with id ${id} already exists`); - } - - const view = this.instantiationService.createInstance( - BrowserView, - id, - browserSession, - // Recursive factory for nested windows (child views share the same session) - (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), - options - ); - this.browserViews.set(id, view); - - this._onTargetCreated.fire(view); - Event.once(view.onDidClose)(() => { - this._onTargetDestroyed.fire(view); - this.browserViews.deleteAndDispose(id); - }); - - return view; - } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { if (this.browserViews.has(id)) { // Note: scope will be ignored if the view already exists. @@ -158,22 +141,15 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.values(); } - async createTarget(url: string, browserContextId?: string): Promise { - const targetId = generateUuid(); - const browserSession = browserContextId && BrowserSession.get(browserContextId) || BrowserSession.getOrCreateEphemeral(targetId); - - // Create the browser view (fires onTargetCreated) - const view = this.createBrowserView(targetId, browserSession); + async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; - logBrowserOpen(this.telemetryService, 'cdpCreated'); - - // Request the workbench to open the editor - this.windowsMainService.sendToFocused('vscode:runAction', { - id: 'vscode.open', - args: [BrowserViewUri.forUrl(url, targetId)] + return this.openNew(url, { + session: browserSession, + windowId, + editorOptions: { preserveFocus: true }, + source: 'cdpCreated' }); - - return view; } async activateTarget(target: ICDPTarget): Promise { @@ -219,8 +195,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa await this.destroyBrowserView(view.id); } } - - browserSession.dispose(); } /** @@ -278,6 +252,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidClose; } + async getState(id: string): Promise { + return this._getBrowserView(id).getState(); + } + async destroyBrowserView(id: string): Promise { return this.browserViews.deleteAndDispose(id); } @@ -306,8 +284,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).goForward(); } - async reload(id: string): Promise { - return this._getBrowserView(id).reload(); + async reload(id: string, hard?: boolean): Promise { + return this._getBrowserView(id).reload(hard); } async toggleDevTools(id: string): Promise { @@ -326,14 +304,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).captureScreenshot(options); } - async dispatchKeyEvent(id: string, keyEvent: IBrowserViewKeyDownEvent): Promise { - return this._getBrowserView(id).dispatchKeyEvent(keyEvent); - } - - async setZoomFactor(id: string, zoomFactor: number): Promise { - return this._getBrowserView(id).setZoomFactor(zoomFactor); - } - async focus(id: string): Promise { return this._getBrowserView(id).focus(); } @@ -354,9 +324,22 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).clearStorage(); } + async setBrowserZoomIndex(id: string, zoomIndex: number): Promise { + return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex); + } + + async trustCertificate(id: string, host: string, fingerprint: string): Promise { + return this._getBrowserView(id).trustCertificate(host, fingerprint); + } + + async untrustCertificate(id: string, host: string, fingerprint: string): Promise { + return this._getBrowserView(id).untrustCertificate(host, fingerprint); + } + async clearGlobalStorage(): Promise { const browserSession = BrowserSession.getOrCreateGlobal(); - await browserSession.electronSession.clearData(); + browserSession.connectStorage(this.applicationStorageMainService); + await browserSession.clearData(); } async clearWorkspaceStorage(workspaceId: string): Promise { @@ -364,6 +347,187 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa workspaceId, this.environmentMainService.workspaceStorageHome ); - await browserSession.electronSession.clearData(); + browserSession.connectStorage(this.applicationStorageMainService); + await browserSession.clearData(); + } + + async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { + this._keybindings = keybindings; + } + + /** + * Create a browser view backed by the given {@link BrowserSession}. + */ + private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + if (this.browserViews.has(id)) { + throw new Error(`Browser view with id ${id} already exists`); + } + + browserSession.connectStorage(this.applicationStorageMainService); + + const view = this.instantiationService.createInstance( + BrowserView, + id, + browserSession, + // Recursive factory for nested windows (child views share the same session) + (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), + (v, params) => this.showContextMenu(v, params), + options + ); + this.browserViews.set(id, view); + + this._onTargetCreated.fire(view); + Event.once(view.onDidClose)(() => { + this._onTargetDestroyed.fire(view); + this.browserViews.deleteAndDispose(id); + }); + + return view; + } + + private async openNew( + url: string, + { + session, + windowId, + editorOptions, + source + }: { + session: BrowserSession | undefined; + windowId: number | undefined; + editorOptions: ITextEditorOptions; + source: IntegratedBrowserOpenSource; + } + ): Promise { + const targetId = generateUuid(); + const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); + + const window = windowId !== undefined ? this.windowsMainService.getWindowById(windowId) : this.windowsMainService.getFocusedWindow(); + if (!window) { + throw new Error(`Window ${windowId} not found`); + } + + + logBrowserOpen(this.telemetryService, source); + + // Request the workbench to open the editor + window.sendWhenReady('vscode:runAction', CancellationToken.None, { + id: '_workbench.open', + args: [BrowserViewUri.forId(targetId), [undefined, { ...editorOptions, viewState: { url } }], undefined] + }); + + return view; + } + + private showContextMenu(view: BrowserView, params: Electron.ContextMenuParams): void { + const win = view.getElectronWindow(); + if (!win) { + return; + } + const webContents = view.webContents; + if (webContents.isDestroyed()) { + return; + } + const menu = new Menu(); + + if (params.linkURL) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), + click: () => { + void this.openNew(params.linkURL, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInExternalBrowser', 'Open Link in External Browser'), + click: () => { void this.nativeHostMainService.openExternal(undefined, params.linkURL); } + })); + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyLink', 'Copy Link'), + click: () => { + clipboard.write({ + text: params.linkURL, + html: `${htmlAttributeEncodeValue(params.linkText || params.linkURL)}` + }); + } + })); + } + + if (params.hasImageContents && params.srcURL) { + if (menu.items.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), + click: () => { + void this.openNew(params.srcURL!, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImage', 'Copy Image'), + click: () => { view.webContents.copyImageAt(params.x, params.y); } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImageUrl', 'Copy Image URL'), + click: () => { clipboard.writeText(params.srcURL!); } + })); + } + + if (params.isEditable) { + menu.append(new MenuItem({ role: 'cut', enabled: params.editFlags.canCut })); + menu.append(new MenuItem({ role: 'copy', enabled: params.editFlags.canCopy })); + menu.append(new MenuItem({ role: 'paste', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'selectAll', enabled: params.editFlags.canSelectAll })); + } else if (params.selectionText) { + menu.append(new MenuItem({ role: 'copy' })); + } + + // Add navigation items as defaults + if (menu.items.length === 0) { + if (webContents.navigationHistory.canGoBack()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.back', 'Back'), + accelerator: this._keybindings[BrowserViewCommandId.GoBack], + click: () => webContents.navigationHistory.goBack() + })); + } + if (webContents.navigationHistory.canGoForward()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.forward', 'Forward'), + accelerator: this._keybindings[BrowserViewCommandId.GoForward], + click: () => webContents.navigationHistory.goForward() + })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.reload', 'Reload'), + accelerator: this._keybindings[BrowserViewCommandId.Reload], + click: () => webContents.reload() + })); + } + + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.inspect', 'Inspect'), + click: () => webContents.inspectElement(params.x, params.y) + })); + + const viewBounds = view.getWebContentsView().getBounds(); + menu.popup({ + window: win, + x: viewBounds.x + params.x, + y: viewBounds.y + params.y, + sourceType: params.menuSourceType + }); } } diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts index b4aaffb612d17..063a5b158b543 100644 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -6,11 +6,9 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { IBrowserViewGroup, IBrowserViewGroupService, IBrowserViewGroupViewEvent, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; - -export const IBrowserViewGroupRemoteService = createDecorator('browserViewGroupRemoteService'); +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; /** * Remote-process service for managing browser view groups. @@ -22,12 +20,11 @@ export const IBrowserViewGroupRemoteService = createDecorator; + createGroup(windowId: number): Promise; } /** @@ -66,8 +63,12 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { return this.groupService.removeViewFromGroup(this.id, viewId); } - async getDebugWebSocketEndpoint(): Promise { - return this.groupService.getDebugWebSocketEndpoint(this.id); + async sendCDPMessage(msg: CDPRequest): Promise { + return this.groupService.sendCDPMessage(this.id, msg); + } + + get onCDPMessage(): Event { + return this.groupService.onDynamicCDPMessage(this.id); } override dispose(fromService = false): void { @@ -79,20 +80,18 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { } export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteService { - declare readonly _serviceBrand: undefined; - private readonly _groupService: IBrowserViewGroupService; private readonly _groups = new Map(); constructor( - @IMainProcessService mainProcessService: IMainProcessService, + mainProcessService: IMainProcessService, ) { const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); this._groupService = ProxyChannel.toService(channel); } - async createGroup(): Promise { - const id = await this._groupService.createGroup(); + async createGroup(windowId: number): Promise { + const id = await this._groupService.createGroup(windowId); return this._wrap(id); } diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts new file mode 100644 index 0000000000000..ca3e83c00a2e5 --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { ILogService } from '../../log/common/log.js'; +import { BrowserViewGroupRemoteService } from './browserViewGroupRemoteService.js'; +import { PlaywrightService } from './playwrightService.js'; + +/** + * IPC channel for the Playwright service. + * + * Each connected window gets its own {@link PlaywrightService}, + * keyed by the opaque IPC connection context. The client sends an + * `__initialize` call with its numeric window ID before any other + * method calls, which eagerly creates the instance. When a window + * disconnects the instance is automatically disposed. + */ +export class PlaywrightChannel extends Disposable implements IServerChannel { + + private readonly _instances = this._register(new DisposableMap()); + private readonly browserViewGroupRemoteService: BrowserViewGroupRemoteService; + + constructor( + ipcServer: IPCServer, + mainProcessService: IMainProcessService, + private readonly logService: ILogService, + ) { + super(); + this.browserViewGroupRemoteService = new BrowserViewGroupRemoteService(mainProcessService); + this._register(ipcServer.onDidRemoveConnection(c => { + this._instances.deleteAndDispose(c.ctx); + })); + } + + listen(ctx: string, event: string): Event { + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + const source = (instance as unknown as Record>)[event]; + if (typeof source !== 'function') { + throw new Error(`Event not found: ${event}`); + } + return source as Event; + } + + call(ctx: string, command: string, arg?: unknown): Promise { + // Handle the one-time initialization call that creates the instance + if (command === '__initialize') { + if (typeof arg !== 'number') { + throw new Error(`Invalid argument for __initialize: expected window ID as number, got ${typeof arg}`); + } + if (!this._instances.has(ctx)) { + const windowId = arg as number; + this._instances.set(ctx, new PlaywrightService(windowId, this.browserViewGroupRemoteService, this.logService)); + } + return Promise.resolve(undefined as T); + } + + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + + const target = (instance as unknown as Record)[command]; + if (typeof target !== 'function') { + throw new Error(`Method not found: ${command}`); + } + + const methodArgs = Array.isArray(arg) ? arg : []; + let res = target.apply(instance, methodArgs); + if (!(res instanceof Promise)) { + res = Promise.resolve(res); + } + return res; + } +} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 3016e0a3659e2..8abf560a5c315 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -10,12 +10,25 @@ import { ILogService } from '../../log/common/log.js'; import { IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; import { PlaywrightTab } from './playwrightTab.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; +interface PlaywrightTransport { + send(s: CDPRequest): void; + close(): void; // Note: calling close is expected to issue onclose at some point. + onmessage?: (message: CDPResponse | CDPEvent) => void; + onclose?: (reason?: string) => void; +} + +declare module 'playwright-core' { + interface BrowserType { + _connectOverCDPTransport(transport: PlaywrightTransport): Promise; + } +} + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -33,8 +46,9 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _initPromise: Promise | undefined; constructor( - @IBrowserViewGroupRemoteService private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, - @ILogService private readonly logService: ILogService, + private readonly windowId: number, + private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, + private readonly logService: ILogService, ) { super(); this._pages = this._register(new PlaywrightPageManager(logService)); @@ -77,12 +91,21 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = await this.browserViewGroupRemoteService.createGroup(); + const group = await this.browserViewGroupRemoteService.createGroup(this.windowId); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); - const endpoint = await group.getDebugWebSocketEndpoint(); - const browser = await playwright.chromium.connectOverCDP(endpoint); + const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); + const transport: PlaywrightTransport = { + close() { + sub.dispose(); + this.onclose?.(); + }, + send(message) { + void group.sendCDPMessage(message); + } + }; + const browser = await playwright.chromium._connectOverCDPTransport(transport); this.logService.debug('[PlaywrightService] Connected to browser'); @@ -125,18 +148,22 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.getSummary(pageId, true); } + async invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + return this._pages.runAgainstPage(pageId, (page) => fn(page, args)); + } + async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); try { - await this.initialize(); - - const vm = await import('vm'); - const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); - let result; try { - result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args)); + result = await this.invokeFunctionRaw(pageId, fnDef, ...args); } catch (err: unknown) { result = err instanceof Error ? err.message : String(err); } @@ -155,16 +182,6 @@ export class PlaywrightService extends Disposable implements IPlaywrightService } } - async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise { - await this.initialize(); - return this._pages.runAgainstPage(pageId, async page => { - const screenshotBuffer = selector - ? await page.locator(selector).screenshot({ type: 'jpeg', quality: 80 }) - : await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); - return VSBuffer.wrap(screenshotBuffer); - }); - } - async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { await this.initialize(); const summary = await this._pages.replyToFileChooser(pageId, files); diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 231daf0fba047..0a73676455fe1 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -42,7 +42,6 @@ export class PlaywrightTab { page.on('console', event => this._handleConsoleMessage(event)) .on('pageerror', error => this._handlePageError(error)) .on('requestfailed', request => this._handleRequestFailed(request)) - .on('filechooser', chooser => this._handleFileChooser(chooser)) .on('dialog', dialog => this._handleDialog(dialog)) .on('download', download => this._handleDownload(download)); @@ -70,7 +69,7 @@ export class PlaywrightTab { async replyToDialog(accept?: boolean, promptText?: string) { if (!this._dialog) { - throw new Error('No active dialog to respond to'); + throw new Error('No active modal dialog to respond to'); } const dialog = this._dialog; this._dialog = undefined; @@ -90,7 +89,7 @@ export class PlaywrightTab { async replyToFileChooser(files: string[]) { if (!this._fileChooser) { - throw new Error('No active file chooser to respond to'); + throw new Error('No active file chooser dialog to respond to'); } const chooser = this._fileChooser; this._fileChooser = undefined; @@ -118,8 +117,12 @@ export class PlaywrightTab { /** * Run a callback against the page and wait for it to complete. + * * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog * appears before the callback finishes, the method throws so the caller can surface it to the agent. + * + * Also allows for interactions to be handled differently when triggered by agents. + * E.g. file dialogs should appear when the user triggers one, but not when the agent does. */ async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { if (this._dialog) { @@ -130,8 +133,20 @@ export class PlaywrightTab { let result: T | void; const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); const actionCompleted = createCancelablePromise(async (token) => { - result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); - actionDidComplete = true; + + // Whenever the page has a `filechooser` handler, the default file chooser is disabled. + // We don't want this during normal user interactions, but we do for agentic interactions. + // So we add a handler just during the action, and remove it afterwards. + // This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort. + const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser); + this.page.on('filechooser', handleFileChooser); + + try { + result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + actionDidComplete = true; + } finally { + this.page.off('filechooser', handleFileChooser); + } }); return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts new file mode 100644 index 0000000000000..4dc9fab0496a6 --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserSessionTrust.test.ts @@ -0,0 +1,340 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { EventEmitter } from 'events'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { StorageScope, StorageTarget } from '../../../storage/common/storage.js'; +import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; +import { BrowserSessionTrust } from '../../electron-main/browserSessionTrust.js'; +import type { BrowserSession } from '../../electron-main/browserSession.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +const STORAGE_KEY = 'browserView.sessionTrustData'; +const TRUST_DURATION_MS = 7 * 24 * 60 * 60 * 1000; + +type CertificateVerifyProc = Parameters[0]; +type CertificateVerifyRequest = Parameters>[0]; + +class TestElectronSession { + readonly closeAllConnections = sinon.stub().resolves(); + certificateVerifyProc: CertificateVerifyProc | undefined; + + setCertificateVerifyProc(callback: CertificateVerifyProc): void { + this.certificateVerifyProc = callback; + } + + asSession(): Electron.Session { + return this as unknown as Electron.Session; + } +} + +class TestBrowserSession { + constructor( + readonly id: string, + readonly electronSession: Electron.Session, + ) { } + + asBrowserSession(): BrowserSession { + return this as unknown as BrowserSession; + } +} + +class TestApplicationStorageMainService { + private readonly data = new Map(); + readonly store = sinon.stub<[string, string | number | boolean | object | null | undefined, StorageScope, StorageTarget], void>().callsFake((key, value) => { + this.data.set(key, String(value)); + }); + readonly remove = sinon.stub<[string, StorageScope], void>().callsFake(key => { + this.data.delete(key); + }); + + get(key: string, _scope: StorageScope, fallbackValue?: string): string | undefined { + return this.data.get(key) ?? fallbackValue; + } + + seed(key: string, value: string): void { + this.data.set(key, value); + } + + read(key: string): string | undefined { + return this.data.get(key); + } + + asService(): IApplicationStorageMainService { + return this as unknown as IApplicationStorageMainService; + } +} + +class TestWebContents extends EventEmitter { + asWebContents(): Electron.WebContents { + return this as unknown as Electron.WebContents; + } +} + +function createTrust(sessionId = 'test-session'): { + trust: BrowserSessionTrust; + electronSession: TestElectronSession; + storage: TestApplicationStorageMainService; +} { + const electronSession = new TestElectronSession(); + const browserSession = new TestBrowserSession(sessionId, electronSession.asSession()); + const trust = new BrowserSessionTrust(browserSession.asBrowserSession()); + const storage = new TestApplicationStorageMainService(); + + return { trust, electronSession, storage }; +} + +function createCertificate(fingerprint: string, extra?: Partial): Electron.Certificate { + return { fingerprint, issuerName: 'Test CA', subjectName: 'test.example.com', validStart: 0, validExpiry: 0, ...extra } as Electron.Certificate; +} + +function invokeVerifyProc( + electronSession: TestElectronSession, + request: Partial & { hostname: string; certificate: Electron.Certificate } +): number { + assert.ok(electronSession.certificateVerifyProc); + + let result: number | undefined; + electronSession.certificateVerifyProc!({ + errorCode: 0, + verificationResult: 'OK', + ...request + } as CertificateVerifyRequest, value => { + result = value; + }); + + assert.notStrictEqual(result, undefined); + return result!; +} + +suite('BrowserSessionTrust', () => { + teardown(() => { + sinon.restore(); + }); + + test('installs certificate verify proc and tracks certificate errors', () => { + const { trust, electronSession } = createTrust(); + + const verificationResult = invokeVerifyProc(electronSession, { + hostname: 'example.com', + errorCode: -202, + verificationResult: 'net::ERR_CERT_AUTHORITY_INVALID', + certificate: createCertificate('abc123') + }); + + assert.strictEqual(verificationResult, -3); + assert.deepStrictEqual(trust.getCertificateError('https://example.com/path'), { + host: 'example.com', + fingerprint: 'abc123', + error: 'net::ERR_CERT_AUTHORITY_INVALID', + url: 'https://example.com/path', + hasTrustedException: false, + issuerName: 'Test CA', + subjectName: 'test.example.com', + validStart: 0, + validExpiry: 0, + }); + + invokeVerifyProc(electronSession, { + hostname: 'example.com', + certificate: createCertificate('abc123') + }); + + assert.strictEqual(trust.getCertificateError('https://example.com/path'), undefined); + }); + + test('trustCertificate persists data under the trust storage key', async () => { + const { trust, storage } = createTrust(); + trust.connectStorage(storage.asService()); + + await trust.trustCertificate('example.com', 'abc123'); + + assert.strictEqual(storage.store.calledOnce, true); + assert.deepStrictEqual(storage.store.firstCall.args.slice(0, 4), [STORAGE_KEY, storage.read(STORAGE_KEY), StorageScope.APPLICATION, StorageTarget.MACHINE]); + + const persisted = JSON.parse(storage.read(STORAGE_KEY)!); + assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'example.com', fingerprint: 'abc123' }]); + }); + + test('trustCertificate stores expiresAt relative to current time', async () => { + const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); + const { trust, storage } = createTrust(); + trust.connectStorage(storage.asService()); + + await trust.trustCertificate('example.com', 'abc123'); + + const persisted = JSON.parse(storage.read(STORAGE_KEY)!); + const [entry] = persisted['test-session'].trustedCerts as { host: string; fingerprint: string; expiresAt: number }[]; + assert.strictEqual(entry.host, 'example.com'); + assert.strictEqual(entry.fingerprint, 'abc123'); + assert.strictEqual(entry.expiresAt, Date.now() + TRUST_DURATION_MS); + + clock.restore(); + }); + + test('trust is valid at expiration and invalid after expiration', async () => { + const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); + const { trust, electronSession, storage } = createTrust(); + const webContents = new TestWebContents(); + trust.installCertErrorHandler(webContents.asWebContents()); + trust.connectStorage(storage.asService()); + await trust.trustCertificate('example.com', 'abc123'); + electronSession.closeAllConnections.resetHistory(); + + // Prior to the expiration boundary, trust should still be valid + clock.tick(TRUST_DURATION_MS - 10); + let callbackResult: boolean | undefined; + const firstEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, true); + + // After expiration, trust should be revoked + clock.tick(20); + const secondEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, false); + + clock.restore(); + }); + + test('connectStorage restores valid trust entries and prunes expired ones', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { trust, storage } = createTrust(); + const webContents = new TestWebContents(); + trust.installCertErrorHandler(webContents.asWebContents()); + storage.seed(STORAGE_KEY, JSON.stringify({ + 'test-session': { + trustedCerts: [ + { host: 'valid.example.com', fingerprint: 'valid', expiresAt: Date.now() + 1000 }, + { host: 'expired.example.com', fingerprint: 'expired', expiresAt: Date.now() - 1000 } + ] + } + })); + + trust.connectStorage(storage.asService()); + + let callbackResult: boolean | undefined; + const validEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', validEvent, 'https://valid.example.com', 'ERR_CERT', createCertificate('valid'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, true); + + const expiredEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', expiredEvent, 'https://expired.example.com', 'ERR_CERT', createCertificate('expired'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, false); + + const persisted = JSON.parse(storage.read(STORAGE_KEY)!); + assert.deepStrictEqual(persisted['test-session'].trustedCerts.map((entry: { host: string; fingerprint: string }) => ({ host: entry.host, fingerprint: entry.fingerprint })), [{ host: 'valid.example.com', fingerprint: 'valid' }]); + })); + + test('stored and reloaded trust expires and is pruned', async () => { + const clock = sinon.useFakeTimers({ now: Date.parse('2026-03-01T00:00:00.000Z') }); + + const storage = new TestApplicationStorageMainService(); + const firstSession = new TestElectronSession(); + const firstBrowserSession = new TestBrowserSession('test-session', firstSession.asSession()); + const firstTrust = new BrowserSessionTrust(firstBrowserSession.asBrowserSession()); + firstTrust.connectStorage(storage.asService()); + await firstTrust.trustCertificate('reload.example.com', 'reload-fingerprint'); + + clock.tick(TRUST_DURATION_MS + 1); + + const secondSession = new TestElectronSession(); + const secondBrowserSession = new TestBrowserSession('test-session', secondSession.asSession()); + const secondTrust = new BrowserSessionTrust(secondBrowserSession.asBrowserSession()); + const webContents = new TestWebContents(); + secondTrust.installCertErrorHandler(webContents.asWebContents()); + secondTrust.connectStorage(storage.asService()); + + let callbackResult: boolean | undefined; + const event = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', event, 'https://reload.example.com', 'ERR_CERT', createCertificate('reload-fingerprint'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, false); + assert.strictEqual(storage.read(STORAGE_KEY), undefined); + + clock.restore(); + }); + + test('untrustCertificate removes persisted trust and closes connections', async () => { + const { trust, electronSession, storage } = createTrust(); + trust.connectStorage(storage.asService()); + await trust.trustCertificate('example.com', 'abc123'); + electronSession.closeAllConnections.resetHistory(); + storage.store.resetHistory(); + + await trust.untrustCertificate('example.com', 'abc123'); + + assert.strictEqual(electronSession.closeAllConnections.calledOnce, true); + assert.strictEqual(storage.remove.calledOnceWithExactly(STORAGE_KEY, StorageScope.APPLICATION), true); + assert.strictEqual(storage.read(STORAGE_KEY), undefined); + }); + + test('untrustCertificate throws when certificate is not found', async () => { + const { trust, electronSession, storage } = createTrust(); + trust.connectStorage(storage.asService()); + + await assert.rejects( + () => trust.untrustCertificate('missing.example.com', 'missing-fingerprint'), + error => { + assert.ok(error instanceof Error); + assert.strictEqual(error.message, 'Certificate not found: host=missing.example.com fingerprint=missing-fingerprint'); + return true; + } + ); + assert.strictEqual(electronSession.closeAllConnections.called, false); + }); + + test('clear removes trust, clears cert errors, and closes connections', async () => { + const { trust, electronSession, storage } = createTrust(); + trust.connectStorage(storage.asService()); + await trust.trustCertificate('example.com', 'abc123'); + invokeVerifyProc(electronSession, { + hostname: 'example.com', + errorCode: -202, + verificationResult: 'net::ERR_CERT_COMMON_NAME_INVALID', + certificate: createCertificate('abc123') + }); + + await trust.clear(); + + assert.strictEqual(electronSession.closeAllConnections.calledOnce, true); + assert.strictEqual(trust.getCertificateError('https://example.com'), undefined); + assert.strictEqual(storage.read(STORAGE_KEY), undefined); + }); + + test('installCertErrorHandler only allows trusted certificates', async () => { + const { trust } = createTrust(); + const webContents = new TestWebContents(); + trust.installCertErrorHandler(webContents.asWebContents()); + + let callbackResult: boolean | undefined; + const firstEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', firstEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, false); + assert.strictEqual(firstEvent.preventDefault.calledOnce, true); + + await trust.trustCertificate('example.com', 'abc123'); + const secondEvent = { preventDefault: sinon.spy() }; + webContents.emit('certificate-error', secondEvent, 'https://example.com', 'ERR_CERT', createCertificate('abc123'), (value: boolean) => { + callbackResult = value; + }); + assert.strictEqual(callbackResult, true); + assert.strictEqual(secondEvent.preventDefault.calledOnce, true); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); +}); diff --git a/src/vs/platform/contextkey/common/contextkey.ts b/src/vs/platform/contextkey/common/contextkey.ts index 22d31d714c99a..595a8d4bbd145 100644 --- a/src/vs/platform/contextkey/common/contextkey.ts +++ b/src/vs/platform/contextkey/common/contextkey.ts @@ -937,11 +937,29 @@ export class ContextKeyInExpr implements IContextKeyExpression { if (Array.isArray(source)) { // eslint-disable-next-line local/code-no-any-casts - return source.includes(item as any); + if (source.includes(item as any)) { + return true; + } + // On Windows, file paths are case-insensitive so file URI + // comparisons must be done in a case-insensitive manner. + if (isWindows && typeof item === 'string' && item.startsWith('file:///')) { + const itemLower = item.toLowerCase(); + return source.some(s => typeof s === 'string' && s.toLowerCase() === itemLower); + } + return false; } if (typeof item === 'string' && typeof source === 'object' && source !== null) { - return hasOwnProperty.call(source, item); + if (hasOwnProperty.call(source, item)) { + return true; + } + // On Windows, file paths are case-insensitive so file URI + // property lookups must be done in a case-insensitive manner. + if (isWindows && item.startsWith('file:///')) { + const itemLower = item.toLowerCase(); + return Object.keys(source).some(key => key.toLowerCase() === itemLower); + } + return false; } return false; } diff --git a/src/vs/platform/contextkey/test/common/contextkey.test.ts b/src/vs/platform/contextkey/test/common/contextkey.test.ts index cf7ebe78a9669..8307e894e2fca 100644 --- a/src/vs/platform/contextkey/test/common/contextkey.test.ts +++ b/src/vs/platform/contextkey/test/common/contextkey.test.ts @@ -183,6 +183,19 @@ suite('ContextKeyExpr', () => { assert.strictEqual(ainb.evaluate(createContext({ 'a': 'x', 'b': { 'x': false } })), true); assert.strictEqual(ainb.evaluate(createContext({ 'a': 'x', 'b': { 'x': true } })), true); assert.strictEqual(ainb.evaluate(createContext({ 'a': 'prototype', 'b': {} })), false); + + // file URI case-insensitive comparison on Windows + if (isWindows) { + // Array source: file URIs with different casing should match on Windows + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/users/path/file.ts'] })), true); + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), true); + // Object source: file URIs with different casing should match on Windows + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': { 'file:///c%3A/users/path/file.ts': true } })), true); + // Non-file URIs should still be case-sensitive + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'git:/path/File.ts', 'b': ['git:/path/file.ts'] })), false); + // Exact match still works + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), true); + } }); test('ContextKeyNotInExpr', () => { @@ -198,6 +211,13 @@ suite('ContextKeyExpr', () => { assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'x', 'b': { 'x': false } })), false); assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'x', 'b': { 'x': true } })), false); assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'prototype', 'b': {} })), true); + + // file URI case-insensitive comparison on Windows + if (isWindows) { + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/users/path/file.ts'] })), false); + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'file:///c%3A/users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), false); + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'git:/path/File.ts', 'b': ['git:/path/file.ts'] })), true); + } }); test('issue #106524: distributing AND should normalize', () => { diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index cd67c68841230..5e543ddd942a7 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -3,15 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; import { Event } from '../../../base/common/event.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; export interface IDefaultAccountProvider { readonly defaultAccount: IDefaultAccount | null; readonly onDidChangeDefaultAccount: Event; readonly policyData: IPolicyData | null; readonly onDidChangePolicyData: Event; + readonly copilotTokenInfo: ICopilotTokenInfo | null; + readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; @@ -25,6 +27,8 @@ export interface IDefaultAccountService { readonly onDidChangeDefaultAccount: Event; readonly onDidChangePolicyData: Event; readonly policyData: IPolicyData | null; + readonly copilotTokenInfo: ICopilotTokenInfo | null; + readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index f80914ca0b58f..fc73e57f82433 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; @@ -37,6 +38,17 @@ export interface IBaseDialogOptions { * Allows to enforce use of custom dialog even in native environments. */ readonly custom?: boolean | ICustomDialogOptions; + + /** + * An optional cancellation token that can be used to dismiss the dialog + * programmatically for custom dialog implementations. + * + * When cancelled, the custom dialog resolves as if the cancel button was + * pressed. Native dialog handlers cannot currently be dismissed + * programmatically and ignore this option unless a custom dialog is + * explicitly enforced via the {@link custom} option. + */ + readonly token?: CancellationToken; } export interface IConfirmDialogArgs { diff --git a/src/vs/platform/download/common/download.ts b/src/vs/platform/download/common/download.ts index d608e078ecbdc..8d26b20e5a35c 100644 --- a/src/vs/platform/download/common/download.ts +++ b/src/vs/platform/download/common/download.ts @@ -13,6 +13,6 @@ export interface IDownloadService { readonly _serviceBrand: undefined; - download(uri: URI, to: URI, cancellationToken?: CancellationToken): Promise; + download(uri: URI, to: URI, callSite: string, cancellationToken?: CancellationToken): Promise; } diff --git a/src/vs/platform/download/common/downloadIpc.ts b/src/vs/platform/download/common/downloadIpc.ts index c3ba6d6c249de..efd3a3e2113c0 100644 --- a/src/vs/platform/download/common/downloadIpc.ts +++ b/src/vs/platform/download/common/downloadIpc.ts @@ -19,7 +19,7 @@ export class DownloadServiceChannel implements IServerChannel { call(context: any, command: string, args?: any): Promise { switch (command) { - case 'download': return this.service.download(URI.revive(args[0]), URI.revive(args[1])); + case 'download': return this.service.download(URI.revive(args[0]), URI.revive(args[1]), args[2] ?? 'downloadIpc'); } throw new Error('Invalid call'); } @@ -31,7 +31,7 @@ export class DownloadServiceChannelClient implements IDownloadService { constructor(private channel: IChannel, private getUriTransformer: () => IURITransformer | null) { } - async download(from: URI, to: URI): Promise { + async download(from: URI, to: URI, _callSite?: string): Promise { const uriTransformer = this.getUriTransformer(); if (uriTransformer) { from = uriTransformer.transformOutgoingURI(from); diff --git a/src/vs/platform/download/common/downloadService.ts b/src/vs/platform/download/common/downloadService.ts index 79cedcb1668ed..4782f50658835 100644 --- a/src/vs/platform/download/common/downloadService.ts +++ b/src/vs/platform/download/common/downloadService.ts @@ -19,13 +19,13 @@ export class DownloadService implements IDownloadService { @IFileService private readonly fileService: IFileService ) { } - async download(resource: URI, target: URI, cancellationToken: CancellationToken = CancellationToken.None): Promise { + async download(resource: URI, target: URI, callSite: string, cancellationToken: CancellationToken = CancellationToken.None): Promise { if (resource.scheme === Schemas.file || resource.scheme === Schemas.vscodeRemote) { // Intentionally only support this for file|remote<->file|remote scenarios await this.fileService.copy(resource, target); return; } - const options = { type: 'GET', url: resource.toString(true) }; + const options = { type: 'GET' as const, url: resource.toString(true), callSite }; const context = await this.requestService.request(options, cancellationToken); if (context.res.statusCode === 200) { await this.fileService.writeFile(target, context.stream); diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 281ee03246a20..b43d8d5313708 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -329,6 +329,26 @@ export interface IEditorOptions { export interface IModalEditorPartOptions { + /** + * Whether the modal editor should be maximized. + */ + readonly maximized?: boolean; + + /** + * Minimum width of the modal editor part in pixels. + */ + readonly minWidth?: number; + + /** + * Size of the modal editor part unless it is maximized. + */ + readonly size?: { readonly width: number; readonly height: number }; + + /** + * Position of the modal editor part unless it is maximized. + */ + readonly position?: { readonly left: number; readonly top: number }; + /** * The navigation context for navigating between items * within this modal editor. Pass `undefined` to clear. diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 45cb23ba6f3f7..72752cf9ed1af 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -24,6 +24,7 @@ export interface NativeParsedArgs { }; }; 'serve-web'?: INativeCliOptions; + 'agent-host'?: INativeCliOptions; chat?: { _: string[]; 'add-file'?: string[]; @@ -86,6 +87,8 @@ export interface NativeParsedArgs { 'inspect-brk-search'?: string; 'inspect-ptyhost'?: string; 'inspect-brk-ptyhost'?: string; + 'inspect-agenthost'?: string; + 'inspect-brk-agenthost'?: string; 'inspect-sharedprocess'?: string; 'inspect-brk-sharedprocess'?: string; 'disable-extensions'?: boolean; @@ -107,6 +110,7 @@ export interface NativeParsedArgs { 'disable-telemetry'?: boolean; 'export-default-configuration'?: string; 'export-policy-data'?: string; + 'export-default-keybindings'?: string; 'install-source'?: string; 'add-mcp'?: string[]; 'disable-updates'?: boolean; @@ -121,6 +125,7 @@ export interface NativeParsedArgs { 'file-write'?: boolean; 'file-chmod'?: boolean; 'enable-smoke-test-driver'?: boolean; + 'skip-sessions-welcome'?: boolean; 'remote'?: string; 'force'?: boolean; 'do-not-sync'?: boolean; diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 137a08dab339a..883ed24b0fc47 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -149,6 +149,7 @@ export interface INativeEnvironmentService extends IEnvironmentService { crossOriginIsolated?: boolean; exportPolicyData?: string; + exportDefaultKeybindings?: string; // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index c6869a109f1a6..3502a718fa0ca 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -264,6 +264,10 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return this.args['export-policy-data']; } + get exportDefaultKeybindings(): string | undefined { + return this.args['export-default-keybindings']; + } + get continueOn(): string | undefined { return this.args['continueOn']; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 6d00ad0ae0908..3b7f625a6ab8e 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -45,7 +45,7 @@ export type OptionDescriptions = { Subcommand }; -export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web'] as const; +export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web', 'agent-host'] as const; export const OPTIONS: OptionDescriptions> = { 'chat': { @@ -71,6 +71,15 @@ export const OPTIONS: OptionDescriptions> = { 'telemetry-level': { type: 'string' }, } }, + 'agent-host': { + type: 'subcommand', + description: 'Run a server that hosts agents.', + options: { + 'cli-data-dir': { type: 'string', args: 'dir', description: localize('cliDataDir', "Directory where CLI metadata should be stored.") }, + 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, + } + }, 'tunnel': { type: 'subcommand', description: 'Make the current machine accessible from vscode.dev or other machines through a secure tunnel.', @@ -158,14 +167,18 @@ export const OPTIONS: OptionDescriptions> = { 'debugRenderer': { type: 'boolean' }, 'inspect-ptyhost': { type: 'string', allowEmptyValue: true }, 'inspect-brk-ptyhost': { type: 'string', allowEmptyValue: true }, + 'inspect-agenthost': { type: 'string', allowEmptyValue: true }, + 'inspect-brk-agenthost': { type: 'string', allowEmptyValue: true }, 'inspect-search': { type: 'string', deprecates: ['debugSearch'], allowEmptyValue: true }, 'inspect-brk-search': { type: 'string', deprecates: ['debugBrkSearch'], allowEmptyValue: true }, 'inspect-sharedprocess': { type: 'string', allowEmptyValue: true }, 'inspect-brk-sharedprocess': { type: 'string', allowEmptyValue: true }, 'export-default-configuration': { type: 'string' }, 'export-policy-data': { type: 'string', allowEmptyValue: true }, + 'export-default-keybindings': { type: 'string', allowEmptyValue: true }, 'install-source': { type: 'string' }, 'enable-smoke-test-driver': { type: 'boolean' }, + 'skip-sessions-welcome': { type: 'boolean' }, 'logExtensionHostCommunication': { type: 'boolean' }, 'skip-release-notes': { type: 'boolean' }, 'skip-welcome': { type: 'boolean' }, diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index ae9e7e1d477c2..1bb9d708407e0 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -25,6 +25,10 @@ export function parsePtyHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): return parseDebugParams(args['inspect-ptyhost'], args['inspect-brk-ptyhost'], 5877, isBuilt, args.extensionEnvironment); } +export function parseAgentHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams { + return parseDebugParams(args['inspect-agenthost'], args['inspect-brk-agenthost'], 5878, isBuilt, args.extensionEnvironment); +} + export function parseSharedProcessDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams { return parseDebugParams(args['inspect-sharedprocess'], args['inspect-brk-sharedprocess'], 5879, isBuilt, args.extensionEnvironment); } diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index e9595bd406a72..426df59122b68 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -167,11 +167,20 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio const results = await this.installGalleryExtensions([{ extension, options }]); const result = results.find(({ identifier }) => areSameExtensions(identifier, extension.identifier)); if (result?.local) { - return result?.local; + return result.local; } if (result?.error) { throw result.error; } + // Extension might have been redirected due to deprecation (e.g., github.copilot -> github.copilot-chat) + // In this case, the result will have the redirected extension's identifier + const redirectedResult = results[0]; + if (redirectedResult?.local) { + return redirectedResult.local; + } + if (redirectedResult?.error) { + throw redirectedResult.error; + } throw new ExtensionManagementError(`Unknown error while installing extension ${extension.identifier.id}`, ExtensionManagementErrorCode.Unknown); } catch (error) { throw toExtensionManagementError(error); diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 40349546ec845..6414a467547a0 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -825,7 +825,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle extension: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension id' }; preRelease: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Get pre-release version' }; compatible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Get compatible version' }; - errorCode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Error code' }; + errorCode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Error code or reason' }; }>('galleryService:fallbacktoquery', { extension: extensionInfo.id, preRelease: !!extensionInfo.preRelease, @@ -1082,7 +1082,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle this.telemetryService.publicLog2('galleryService:engineFallback', { extension: extensionId, extensionVersion: version }); const headers = { 'Accept-Encoding': 'gzip' }; - const context = await this.getAsset(extensionId, manifestAsset, AssetType.Manifest, version, { headers }); + const context = await this.getAsset(extensionId, manifestAsset, AssetType.Manifest, version, 'extensionGalleryService.engineVersion', { headers }); const manifest = await asJson(context); if (!manifest) { this.logService.error(`Manifest was not found for the extension ${extensionId} with version ${version}`); @@ -1439,7 +1439,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle type: 'POST', url: extensionsQueryApi, data, - headers + headers, + callSite: 'extensionGalleryService.queryRawGalleryExtensions' }, token); if (context.res.statusCode && context.res.statusCode >= 400 && context.res.statusCode < 500) { @@ -1588,7 +1589,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle type: 'GET', url: uri.toString(true), headers, - timeout: this.getRequestTimeout() + timeout: this.getRequestTimeout(), + callSite: 'extensionGalleryService.getLatestRawGalleryExtension' }, token); if (context.res.statusCode === 404) { @@ -1686,7 +1688,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle await this.requestService.request({ type: 'POST', url, - headers + headers, + callSite: 'extensionGalleryService.reportStatistic' }, CancellationToken.None); } catch (error) { /* Ignore */ } } @@ -1704,7 +1707,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const activityId = extension.queryContext?.[SEARCH_ACTIVITY_HEADER_NAME]; const headers: IHeaders | undefined = activityId && typeof activityId === 'string' ? { [SEARCH_ACTIVITY_HEADER_NAME]: activityId } : undefined; - const context = await this.getAsset(extension.identifier.id, downloadAsset, AssetType.VSIX, extension.version, headers ? { headers } : undefined); + const context = await this.getAsset(extension.identifier.id, downloadAsset, AssetType.VSIX, extension.version, 'extensionGalleryService.download', headers ? { headers } : undefined); try { await this.fileService.writeFile(location, context.stream); @@ -1737,7 +1740,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle this.logService.trace('ExtensionGalleryService#downloadSignatureArchive', extension.identifier.id); - const context = await this.getAsset(extension.identifier.id, extension.assets.signature, AssetType.Signature, extension.version); + const context = await this.getAsset(extension.identifier.id, extension.assets.signature, AssetType.Signature, extension.version, 'extensionGalleryService.signature'); try { await this.fileService.writeFile(location, context.stream); } catch (error) { @@ -1754,7 +1757,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle async getReadme(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.readme) { - const context = await this.getAsset(extension.identifier.id, extension.assets.readme, AssetType.Details, extension.version, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.readme, AssetType.Details, extension.version, 'extensionGalleryService.readme', {}, token); const content = await asTextOrError(context); return content || ''; } @@ -1763,7 +1766,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle async getManifest(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.manifest) { - const context = await this.getAsset(extension.identifier.id, extension.assets.manifest, AssetType.Manifest, extension.version, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.manifest, AssetType.Manifest, extension.version, 'extensionGalleryService.manifest', {}, token); const text = await asTextOrError(context); return text ? JSON.parse(text) : null; } @@ -1773,7 +1776,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle async getCoreTranslation(extension: IGalleryExtension, languageId: string): Promise { const asset = extension.assets.coreTranslations.filter(t => t[0] === languageId.toUpperCase())[0]; if (asset) { - const context = await this.getAsset(extension.identifier.id, asset[1], asset[0], extension.version); + const context = await this.getAsset(extension.identifier.id, asset[1], asset[0], extension.version, 'extensionGalleryService.coreTranslation'); const text = await asTextOrError(context); return text ? JSON.parse(text) : null; } @@ -1782,7 +1785,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle async getChangelog(extension: IGalleryExtension, token: CancellationToken): Promise { if (extension.assets.changelog) { - const context = await this.getAsset(extension.identifier.id, extension.assets.changelog, AssetType.Changelog, extension.version, {}, token); + const context = await this.getAsset(extension.identifier.id, extension.assets.changelog, AssetType.Changelog, extension.version, 'extensionGalleryService.changelog', {}, token); const content = await asTextOrError(context); return content || ''; } @@ -1869,7 +1872,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle return result; } - private async getAsset(extension: string, asset: IGalleryExtensionAsset, assetType: string, extensionVersion: string, options: IRequestOptions = {}, token: CancellationToken = CancellationToken.None): Promise { + private async getAsset(extension: string, asset: IGalleryExtensionAsset, assetType: string, extensionVersion: string, callSite: string, options: Omit = {}, token: CancellationToken = CancellationToken.None): Promise { const commonHeaders = await this.commonHeadersPromise; const baseOptions = { type: 'GET' }; const headers = { ...commonHeaders, ...(options.headers || {}) }; @@ -1877,7 +1880,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const url = asset.uri; const fallbackUrl = asset.fallbackUri; - const firstOptions = { ...options, url, timeout: this.getRequestTimeout() }; + const firstOptions = { ...options, url, timeout: this.getRequestTimeout(), callSite }; let context; try { @@ -1923,7 +1926,7 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle endToEndId: this.getHeaderValue(context?.res.headers, END_END_ID_HEADER_NAME), }); - const fallbackOptions = { ...options, url: fallbackUrl, timeout: this.getRequestTimeout() }; + const fallbackOptions = { ...options, url: fallbackUrl, timeout: this.getRequestTimeout(), callSite: `${callSite}.fallback` }; return this.requestService.request(fallbackOptions, token); } } @@ -1942,7 +1945,8 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const context = await this.requestService.request({ type: 'GET', url: this.extensionsControlUrl, - timeout: this.getRequestTimeout() + timeout: this.getRequestTimeout(), + callSite: 'extensionGalleryService.getExtensionsControlManifest' }, CancellationToken.None); if (context.res.statusCode !== 200) { diff --git a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts index 47f9736ff5af5..63a60f3a3d9ca 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagementIpc.ts @@ -54,11 +54,11 @@ export class ExtensionManagementChannel; constructor(private service: IExtensionManagementService, private getUriTransformer: (requestContext: TContext) => IURITransformer | null) { - this.onInstallExtension = Event.buffer(service.onInstallExtension, true); - this.onDidInstallExtensions = Event.buffer(service.onDidInstallExtensions, true); - this.onUninstallExtension = Event.buffer(service.onUninstallExtension, true); - this.onDidUninstallExtension = Event.buffer(service.onDidUninstallExtension, true); - this.onDidUpdateExtensionMetadata = Event.buffer(service.onDidUpdateExtensionMetadata, true); + this.onInstallExtension = Event.buffer(service.onInstallExtension, 'onInstallExtension', true); + this.onDidInstallExtensions = Event.buffer(service.onDidInstallExtensions, 'onDidInstallExtensions', true); + this.onUninstallExtension = Event.buffer(service.onUninstallExtension, 'onUninstallExtension', true); + this.onDidUninstallExtension = Event.buffer(service.onDidUninstallExtension, 'onDidUninstallExtension', true); + this.onDidUpdateExtensionMetadata = Event.buffer(service.onDidUpdateExtensionMetadata, 'onDidUpdateExtensionMetadata', true); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/vs/platform/extensionManagement/node/extensionManagementService.ts b/src/vs/platform/extensionManagement/node/extensionManagementService.ts index cdf0c67facd71..cef0d3c59c1e0 100644 --- a/src/vs/platform/extensionManagement/node/extensionManagementService.ts +++ b/src/vs/platform/extensionManagement/node/extensionManagementService.ts @@ -261,7 +261,7 @@ export class ExtensionManagementService extends AbstractExtensionManagementServi } this.logService.trace('Downloading extension from', vsix.toString()); const location = joinPath(this.extensionsDownloader.extensionsDownloadDir, generateUuid()); - await this.downloadService.download(vsix, location); + await this.downloadService.download(vsix, location, 'extensionManagement.downloadVsix'); this.logService.info('Downloaded extension to', location.toString()); const cleanup = async () => { try { diff --git a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts index e360b8431935b..638db3465469c 100644 --- a/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts +++ b/src/vs/platform/extensionResourceLoader/common/extensionResourceLoaderService.ts @@ -34,7 +34,7 @@ export class ExtensionResourceLoaderService extends AbstractExtensionResourceLoa async readExtensionResource(uri: URI): Promise { if (await this.isExtensionGalleryResource(uri)) { const headers = await this.getExtensionGalleryRequestHeaders(); - const requestContext = await this._requestService.request({ url: uri.toString(), headers }, CancellationToken.None); + const requestContext = await this._requestService.request({ url: uri.toString(), headers, callSite: 'extensionResourceLoader.readExtensionResource' }, CancellationToken.None); return (await asTextOrError(requestContext)) || ''; } const result = await this._fileService.readFile(uri); diff --git a/src/vs/platform/extensions/common/extensionHostStarter.ts b/src/vs/platform/extensions/common/extensionHostStarter.ts index 3560e56c19c6e..81574ba47f9ee 100644 --- a/src/vs/platform/extensions/common/extensionHostStarter.ts +++ b/src/vs/platform/extensions/common/extensionHostStarter.ts @@ -9,6 +9,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IExtensionHostStarter = createDecorator('extensionHostStarter'); export const ipcExtensionHostStarterChannelName = 'extensionHostStarter'; +export const extensionHostGraceTimeMs = 6000; export interface IExtensionHostProcessOptions { responseWindowId: number; @@ -31,6 +32,7 @@ export interface IExtensionHostStarter { createExtensionHost(): Promise<{ id: string }>; start(id: string, opts: IExtensionHostProcessOptions): Promise<{ pid: number | undefined }>; enableInspectPort(id: string): Promise; + waitForExit(id: string, maxWaitTimeMs: number): Promise; kill(id: string): Promise; } diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index 3e5b309b82643..e1a24c7190bde 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -237,6 +237,7 @@ export interface IExtensionContributions { readonly chatInstructions?: ReadonlyArray; readonly chatAgents?: ReadonlyArray; readonly chatSkills?: ReadonlyArray; + readonly chatPlugins?: ReadonlyArray; readonly languageModelTools?: ReadonlyArray; readonly languageModelToolSets?: ReadonlyArray; readonly mcpServerDefinitionProviders?: ReadonlyArray; diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index db714398cdaad..9b1af5d17b815 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -37,6 +37,9 @@ const _allApiProposals = { authenticationChallenges: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts', }, + browser: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.browser.d.ts', + }, canonicalUriProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.canonicalUriProvider.d.ts', }, @@ -45,7 +48,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 1 + version: 4 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', @@ -60,7 +63,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 14 + version: 15 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', @@ -314,6 +317,7 @@ const _allApiProposals = { }, mcpServerDefinitions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts', + version: 1 }, mcpToolDefinitions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpToolDefinitions.d.ts', @@ -426,6 +430,9 @@ const _allApiProposals = { taskProblemMatcherStatus: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskProblemMatcherStatus.d.ts', }, + taskRunOptions: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.taskRunOptions.d.ts', + }, telemetry: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.telemetry.d.ts', }, @@ -480,6 +487,9 @@ const _allApiProposals = { tokenInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tokenInformation.d.ts', }, + toolInvocationApproveCombination: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts', + }, toolProgress: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.toolProgress.d.ts', }, diff --git a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts index 97a0519a493cb..d5cddd2c8cdf2 100644 --- a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts +++ b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts @@ -7,7 +7,7 @@ import { Promises } from '../../../base/common/async.js'; import { canceled } from '../../../base/common/errors.js'; import { Event } from '../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; -import { IExtensionHostProcessOptions, IExtensionHostStarter } from '../common/extensionHostStarter.js'; +import { extensionHostGraceTimeMs, IExtensionHostProcessOptions, IExtensionHostStarter } from '../common/extensionHostStarter.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -121,7 +121,7 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx allowLoadingUnsignedLibraries: true, respondToAuthRequestsFromMainProcess: true, windowLifecycleBound: true, - windowLifecycleGraceTime: 6000, + windowLifecycleGraceTime: extensionHostGraceTimeMs, correlationId: id }); const pid = await Event.toPromise(extHost.onSpawn); @@ -151,6 +151,17 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx extHostProcess.kill(); } + async waitForExit(id: string, maxWaitTimeMs: number): Promise { + if (this._shutdown) { + throw canceled(); + } + const extHostProcess = this._extHosts.get(id); + if (!extHostProcess) { + return; + } + await extHostProcess.waitForExit(maxWaitTimeMs); + } + async _killAllNow(): Promise { for (const [, extHost] of this._extHosts) { extHost.kill(); diff --git a/src/vs/platform/hover/browser/hover.css b/src/vs/platform/hover/browser/hover.css index 597738d306964..9a6b49d73fe90 100644 --- a/src/vs/platform/hover/browser/hover.css +++ b/src/vs/platform/hover/browser/hover.css @@ -17,7 +17,11 @@ border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: 5px; color: var(--vscode-editorHoverWidget-foreground); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-hover); +} + +.monaco-hover.workbench-hover.with-pointer { + border-radius: 3px; } .monaco-hover.workbench-hover .monaco-action-bar .action-item .codicon { diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index 116bfe0824cc4..cfb53e2e686ae 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -248,6 +248,7 @@ export class HoverService extends Disposable implements IHoverService { } private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): ICreateHoverResult | undefined { + this._currentDelayedHover?.dispose(); this._currentDelayedHover = undefined; if (options.content === '') { @@ -556,7 +557,7 @@ export class HoverService extends Disposable implements IHoverService { if (targetElement.title !== '') { console.warn('HTML element already has a title attribute, which will conflict with the custom hover. Please remove the title attribute.'); - console.trace('Stack trace:', targetElement.title); + // console.trace('Stack trace:', targetElement.title); targetElement.title = ''; } diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 41c8723608abd..28af860840d15 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -138,6 +138,9 @@ export class HoverWidget extends Widget implements IHoverWidget { if (options.appearance?.compact) { this._hover.containerDomNode.classList.add('workbench-hover', 'compact'); } + if (this._hoverPointer) { + this._hover.containerDomNode.classList.add('with-pointer'); + } if (options.additionalClasses) { this._hover.containerDomNode.classList.add(...options.additionalClasses); } diff --git a/src/vs/platform/instantiation/common/extensions.ts b/src/vs/platform/instantiation/common/extensions.ts index 517a8cc2a3a08..e59cc837cc633 100644 --- a/src/vs/platform/instantiation/common/extensions.ts +++ b/src/vs/platform/instantiation/common/extensions.ts @@ -26,7 +26,7 @@ export function registerSingleton(id: Serv export function registerSingleton(id: ServiceIdentifier, descriptor: SyncDescriptor): void; export function registerSingleton(id: ServiceIdentifier, ctorOrDescriptor: { new(...services: Services): T } | SyncDescriptor, supportsDelayedInstantiation?: boolean | InstantiationType): void { if (!(ctorOrDescriptor instanceof SyncDescriptor)) { - ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor as new (...args: any[]) => T, [], Boolean(supportsDelayedInstantiation)); + ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor as new (...args: unknown[]) => T, [], Boolean(supportsDelayedInstantiation)); } _registry.push([id, ctorOrDescriptor]); diff --git a/src/vs/platform/keybinding/common/keybindingsRegistry.ts b/src/vs/platform/keybinding/common/keybindingsRegistry.ts index 05660234e6151..097d8a73a8499 100644 --- a/src/vs/platform/keybinding/common/keybindingsRegistry.ts +++ b/src/vs/platform/keybinding/common/keybindingsRegistry.ts @@ -77,6 +77,7 @@ export interface IKeybindingsRegistry { setExtensionKeybindings(rules: IExtensionKeybindingRule[]): void; registerCommandAndKeybindingRule(desc: ICommandAndKeybindingRule): IDisposable; getDefaultKeybindings(): IKeybindingItem[]; + getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[]; } /** @@ -85,24 +86,23 @@ export interface IKeybindingsRegistry { class KeybindingsRegistryImpl implements IKeybindingsRegistry { private _coreKeybindings: LinkedList; + private _coreKeybindingRules: LinkedList; private _extensionKeybindings: IKeybindingItem[]; private _cachedMergedKeybindings: IKeybindingItem[] | null; constructor() { this._coreKeybindings = new LinkedList(); + this._coreKeybindingRules = new LinkedList(); this._extensionKeybindings = []; this._cachedMergedKeybindings = null; } - /** - * Take current platform into account and reduce to primary & secondary. - */ - private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { - if (OS === OperatingSystem.Windows) { + private static bindToPlatform(kb: IKeybindings, os: OperatingSystem): { primary?: number; secondary?: number[] } { + if (os === OperatingSystem.Windows) { if (kb && kb.win) { return kb.win; } - } else if (OS === OperatingSystem.Macintosh) { + } else if (os === OperatingSystem.Macintosh) { if (kb && kb.mac) { return kb.mac; } @@ -111,10 +111,16 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { return kb.linux; } } - return kb; } + /** + * Take current platform into account and reduce to primary & secondary. + */ + private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { + return KeybindingsRegistryImpl.bindToPlatform(kb, OS); + } + public registerKeybindingRule(rule: IKeybindingRule): IDisposable { const actualKb = KeybindingsRegistryImpl.bindToCurrentPlatform(rule); const result = new DisposableStore(); @@ -135,6 +141,10 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } } } + + const removeRule = this._coreKeybindingRules.push(rule); + result.add(toDisposable(() => { removeRule(); })); + return result; } @@ -193,6 +203,51 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } return this._cachedMergedKeybindings.slice(0); } + + public getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[] { + const result: IKeybindingItem[] = []; + for (const rule of this._coreKeybindingRules) { + const actualKb = KeybindingsRegistryImpl.bindToPlatform(rule, os); + + if (actualKb && actualKb.primary) { + const kk = decodeKeybinding(actualKb.primary, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: 0, + extensionId: null, + isBuiltinExtension: false + }); + } + } + + if (actualKb && Array.isArray(actualKb.secondary)) { + for (let i = 0, len = actualKb.secondary.length; i < len; i++) { + const k = actualKb.secondary[i]; + const kk = decodeKeybinding(k, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: -i - 1, + extensionId: null, + isBuiltinExtension: false + }); + } + } + } + } + + result.sort(sorter); + return result; + } } export const KeybindingsRegistry: IKeybindingsRegistry = new KeybindingsRegistryImpl(); diff --git a/src/vs/platform/keyboardLayout/common/keyboardLayout.ts b/src/vs/platform/keyboardLayout/common/keyboardLayout.ts index a1db5be9f3248..3e96e1622cecc 100644 --- a/src/vs/platform/keyboardLayout/common/keyboardLayout.ts +++ b/src/vs/platform/keyboardLayout/common/keyboardLayout.ts @@ -132,13 +132,13 @@ export function parseKeyboardLayoutDescription(layout: IKeyboardLayoutInfo | nul if (/^com\.apple\.keylayout\./.test(macLayout.id)) { return { - label: macLayout.id.replace(/^com\.apple\.keylayout\./, '').replace(/-/, ' '), + label: macLayout.id.replace(/^com\.apple\.keylayout\./, '').replace(/-/g, ' '), description: '' }; } if (/^.*inputmethod\./.test(macLayout.id)) { return { - label: macLayout.id.replace(/^.*inputmethod\./, '').replace(/[-\.]/, ' '), + label: macLayout.id.replace(/^.*inputmethod\./, '').replace(/[-\.]/g, ' '), description: `Input Method (${macLayout.lang})` }; } diff --git a/src/vs/platform/label/common/label.ts b/src/vs/platform/label/common/label.ts index 1da00285dc852..11d3f7c7d9261 100644 --- a/src/vs/platform/label/common/label.ts +++ b/src/vs/platform/label/common/label.ts @@ -66,4 +66,10 @@ export interface ResourceLabelFormatting { workspaceTooltip?: string; authorityPrefix?: string; stripPathStartingSeparator?: boolean; + /** + * Number of leading path segments to strip from `${path}` before + * substitution. For example, a value of `2` turns + * `/scheme/authority/rest/of/path` into `/rest/of/path`. + */ + stripPathSegments?: number; } diff --git a/src/vs/platform/launch/electron-main/launchMainService.ts b/src/vs/platform/launch/electron-main/launchMainService.ts index db2fd75d13495..274600742e45a 100644 --- a/src/vs/platform/launch/electron-main/launchMainService.ts +++ b/src/vs/platform/launch/electron-main/launchMainService.ts @@ -18,6 +18,7 @@ import { ICodeWindow } from '../../window/electron-main/window.js'; import { IWindowSettings } from '../../window/common/window.js'; import { IOpenConfiguration, IWindowsMainService, OpenContext } from '../../windows/electron-main/windows.js'; import { IProtocolUrl } from '../../url/electron-main/url.js'; +import { IProductService } from '../../product/common/productService.js'; export const ID = 'launchMainService'; export const ILaunchMainService = createDecorator(ID); @@ -45,6 +46,7 @@ export class LaunchMainService implements ILaunchMainService { @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IURLService private readonly urlService: IURLService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, ) { } async start(args: NativeParsedArgs, userEnv: IProcessEnvironment): Promise { @@ -111,6 +113,7 @@ export class LaunchMainService implements ILaunchMainService { private async startOpenWindow(args: NativeParsedArgs, userEnv: IProcessEnvironment): Promise { const context = isLaunchedFromCli(userEnv) ? OpenContext.CLI : OpenContext.DESKTOP; + let usedWindows: ICodeWindow[] = []; const waitMarkerFileURI = args.wait && args.waitMarkerFilePath ? URI.file(args.waitMarkerFilePath) : undefined; @@ -142,6 +145,11 @@ export class LaunchMainService implements ILaunchMainService { await this.windowsMainService.openExtensionDevelopmentHostWindow(args.extensionDevelopmentPath, baseConfig); } + // Sessions window + else if (args['sessions'] && this.productService.quality !== 'stable') { + usedWindows = await this.windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); + } + // Start without file/folder arguments else if (!args._.length && !args['folder-uri'] && !args['file-uri']) { let openNewWindow = false; diff --git a/src/vs/platform/mcp/common/mcpGalleryManifestService.ts b/src/vs/platform/mcp/common/mcpGalleryManifestService.ts index 377d30712606d..a86f67c36d46b 100644 --- a/src/vs/platform/mcp/common/mcpGalleryManifestService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryManifestService.ts @@ -134,6 +134,7 @@ export class McpGalleryManifestService extends Disposable implements IMcpGallery const context = await this.requestService.request({ type: 'GET', url: `${url}/${version}/servers?limit=1`, + callSite: 'mcpGalleryManifestService.checkVersion' }, CancellationToken.None); if (isSuccess(context)) { return true; diff --git a/src/vs/platform/mcp/common/mcpGalleryService.ts b/src/vs/platform/mcp/common/mcpGalleryService.ts index 5de645fe43699..6dbb16e296094 100644 --- a/src/vs/platform/mcp/common/mcpGalleryService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryService.ts @@ -816,6 +816,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url: readmeUrl, + callSite: 'mcpGalleryService.getReadme' }, token); const result = await asText(context); @@ -951,6 +952,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url, + callSite: 'mcpGalleryService.queryMcpServers' }, token); const data = await asJson(context); @@ -972,6 +974,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url: mcpServerUrl, + callSite: 'mcpGalleryService.getMcpServer' }, CancellationToken.None); if (context.res.statusCode && context.res.statusCode >= 400 && context.res.statusCode < 500) { diff --git a/src/vs/platform/mcp/common/mcpGateway.ts b/src/vs/platform/mcp/common/mcpGateway.ts index 27fd54269229e..ccc91ac7f0a6d 100644 --- a/src/vs/platform/mcp/common/mcpGateway.ts +++ b/src/vs/platform/mcp/common/mcpGateway.ts @@ -13,39 +13,61 @@ export const IMcpGatewayService = createDecorator('IMcpGatew export const McpGatewayChannelName = 'mcpGateway'; export const McpGatewayToolBrokerChannelName = 'mcpGatewayToolBroker'; -export interface IGatewayCallToolResult { - result: MCP.CallToolResult; - serverIndex: number; +/** + * Descriptor for an MCP server known to the gateway. + */ +export interface IMcpGatewayServerDescriptor { + readonly id: string; + readonly label: string; } -export interface IGatewayServerResources { - serverIndex: number; - resources: readonly MCP.Resource[]; +/** + * A single server entry exposed by the gateway. + */ +export interface IMcpGatewayServerInfo { + readonly label: string; + readonly address: URI; } -export interface IGatewayServerResourceTemplates { - serverIndex: number; - resourceTemplates: readonly MCP.ResourceTemplate[]; +/** + * Per-server tool invoker used by a single gateway route/session. + * All methods operate on the specific server this invoker is bound to. + */ +export interface IMcpGatewaySingleServerInvoker { + readonly onDidChangeTools: Event; + readonly onDidChangeResources: Event; + listTools(): Promise; + callTool(name: string, args: Record): Promise; + listResources(): Promise; + readResource(uri: string): Promise; + listResourceTemplates(): Promise; } +/** + * Aggregating tool invoker that provides per-server operations and + * server lifecycle tracking. Used by the gateway service to create + * and manage per-server routes. + */ export interface IMcpGatewayToolInvoker { + readonly onDidChangeServers: Event; readonly onDidChangeTools: Event; readonly onDidChangeResources: Event; - listTools(): Promise; - callTool(name: string, args: Record): Promise; - listResources(): Promise; - readResource(serverIndex: number, uri: string): Promise; - listResourceTemplates(): Promise; + listServers(): readonly IMcpGatewayServerDescriptor[]; + listToolsForServer(serverId: string): Promise; + callToolForServer(serverId: string, name: string, args: Record): Promise; + listResourcesForServer(serverId: string): Promise; + readResourceForServer(serverId: string, uri: string): Promise; + listResourceTemplatesForServer(serverId: string): Promise; } /** - * Result of creating an MCP gateway. + * Serializable result of creating an MCP gateway (safe for IPC). */ -export interface IMcpGatewayInfo { +export interface IMcpGatewayDto { /** - * The address of the HTTP endpoint for this gateway. + * The servers currently exposed by this gateway. */ - readonly address: URI; + readonly servers: readonly IMcpGatewayServerInfo[]; /** * The unique identifier for this gateway, used for disposal. @@ -53,6 +75,16 @@ export interface IMcpGatewayInfo { readonly gatewayId: string; } +/** + * Result of creating an MCP gateway (in-process, includes event). + */ +export interface IMcpGatewayInfo extends IMcpGatewayDto { + /** + * Event that fires when the set of servers changes. + */ + readonly onDidChangeServers: Event; +} + /** * Service that manages MCP gateway HTTP endpoints in the main process (or remote server). * diff --git a/src/vs/platform/mcp/common/mcpManagement.ts b/src/vs/platform/mcp/common/mcpManagement.ts index 9c2b7e73d9023..834068a98b6c7 100644 --- a/src/vs/platform/mcp/common/mcpManagement.ts +++ b/src/vs/platform/mcp/common/mcpManagement.ts @@ -10,13 +10,14 @@ import { IIterativePager } from '../../../base/common/paging.js'; import { URI } from '../../../base/common/uri.js'; import { SortBy, SortOrder } from '../../extensionManagement/common/extensionManagement.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js'; +import { IMcpSandboxConfiguration, IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js'; export type InstallSource = 'gallery' | 'local'; export interface ILocalMcpServer { readonly name: string; readonly config: IMcpServerConfiguration; + readonly rootSandbox?: IMcpSandboxConfiguration; readonly version?: string; readonly mcpResource: URI; readonly location?: URI; diff --git a/src/vs/platform/mcp/common/mcpManagementIpc.ts b/src/vs/platform/mcp/common/mcpManagementIpc.ts index 733319fd2161a..570ede9d027df 100644 --- a/src/vs/platform/mcp/common/mcpManagementIpc.ts +++ b/src/vs/platform/mcp/common/mcpManagementIpc.ts @@ -46,11 +46,11 @@ export class McpManagementChannel; constructor(private service: IMcpManagementService, private getUriTransformer: (requestContext: TContext) => IURITransformer | null) { - this.onInstallMcpServer = Event.buffer(service.onInstallMcpServer, true); - this.onDidInstallMcpServers = Event.buffer(service.onDidInstallMcpServers, true); - this.onDidUpdateMcpServers = Event.buffer(service.onDidUpdateMcpServers, true); - this.onUninstallMcpServer = Event.buffer(service.onUninstallMcpServer, true); - this.onDidUninstallMcpServer = Event.buffer(service.onDidUninstallMcpServer, true); + this.onInstallMcpServer = Event.buffer(service.onInstallMcpServer, 'onInstallMcpServer', true); + this.onDidInstallMcpServers = Event.buffer(service.onDidInstallMcpServers, 'onDidInstallMcpServers', true); + this.onDidUpdateMcpServers = Event.buffer(service.onDidUpdateMcpServers, 'onDidUpdateMcpServers', true); + this.onUninstallMcpServer = Event.buffer(service.onUninstallMcpServer, 'onUninstallMcpServer', true); + this.onDidUninstallMcpServer = Event.buffer(service.onDidUninstallMcpServer, 'onDidUninstallMcpServer', true); } listen(context: TContext, event: string): Event { diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 5f72d29a8fe03..ec10b0f0ea947 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -22,7 +22,7 @@ import { ILogService } from '../../log/common/log.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js'; import { DidUninstallMcpServerEvent, IGalleryMcpServer, ILocalMcpServer, IMcpGalleryService, IMcpManagementService, IMcpServerInput, IGalleryMcpServerConfiguration, InstallMcpServerEvent, InstallMcpServerResult, RegistryType, UninstallMcpServerEvent, InstallOptions, UninstallOptions, IInstallableMcpServer, IAllowedMcpServersService, IMcpServerArgument, IMcpServerKeyValueInput, McpServerConfigurationParseResult } from './mcpManagement.js'; -import { IMcpServerVariable, McpServerVariableType, IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js'; +import { IMcpSandboxConfiguration, IMcpServerVariable, McpServerVariableType, IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js'; import { IMcpResourceScannerService, McpResourceTarget } from './mcpResourceScannerService.js'; export interface ILocalMcpServerInfo { @@ -358,7 +358,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo const scannedMcpServers = await this.mcpResourceScannerService.scanMcpServers(this.mcpResource, this.target); if (scannedMcpServers.servers) { await Promise.allSettled(Object.entries(scannedMcpServers.servers).map(async ([name, scannedServer]) => { - const server = await this.scanLocalServer(name, scannedServer); + const server = await this.scanLocalServer(name, scannedServer, scannedMcpServers.sandbox); local.set(name, server); })); } @@ -426,7 +426,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo return Array.from(this.local.values()); } - protected async scanLocalServer(name: string, config: IMcpServerConfiguration): Promise { + protected async scanLocalServer(name: string, config: IMcpServerConfiguration, rootSandbox?: IMcpSandboxConfiguration): Promise { let mcpServerInfo = await this.getLocalServerInfo(name, config); if (!mcpServerInfo) { mcpServerInfo = { name, version: config.version, galleryUrl: isString(config.gallery) ? config.gallery : undefined }; @@ -435,6 +435,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo return { name, config, + rootSandbox, mcpResource: this.mcpResource, version: mcpServerInfo.version, location: mcpServerInfo.location, diff --git a/src/vs/platform/mcp/common/mcpPlatformTypes.ts b/src/vs/platform/mcp/common/mcpPlatformTypes.ts index 985d17f1dc7ac..dc4fb38172e7c 100644 --- a/src/vs/platform/mcp/common/mcpPlatformTypes.ts +++ b/src/vs/platform/mcp/common/mcpPlatformTypes.ts @@ -58,7 +58,6 @@ export interface IMcpStdioServerConfiguration extends ICommonMcpServerConfigurat readonly envFile?: string; readonly cwd?: string; readonly sandboxEnabled?: boolean; - readonly sandbox?: IMcpSandboxConfiguration; readonly dev?: IMcpDevModeConfig; } diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index cd8e0a9f0eb88..151238228b5e3 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -47,6 +47,7 @@ export interface IMcpResourceScannerService { readonly _serviceBrand: undefined; scanMcpServers(mcpResource: URI, target?: McpResourceTarget): Promise; addMcpServers(servers: IInstallableMcpServer[], mcpResource: URI, target?: McpResourceTarget): Promise; + updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise; removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise; } @@ -82,6 +83,10 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc }); } + async updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise { + await this.withProfileMcpServers(mcpResource, target, updateFn); + } + async removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise { await this.withProfileMcpServers(mcpResource, target, scannedMcpServers => { for (const serverName of serverNames) { @@ -139,7 +144,9 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc } private async writeScannedMcpServers(mcpResource: URI, scannedMcpServers: IScannedMcpServers): Promise { - if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0) || (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0)) { + if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0) + || (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0) + || scannedMcpServers.sandbox !== undefined) { await this.fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify(scannedMcpServers, null, '\t'))); } else { await this.fileService.del(mcpResource); @@ -181,7 +188,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (servers.length > 0) { userMcpServers.servers = {}; for (const [serverName, server] of servers) { - userMcpServers.servers[serverName] = this.sanitizeServer(server, scannedMcpServers.sandbox); + userMcpServers.servers[serverName] = this.sanitizeServer(server); } } return userMcpServers; @@ -196,13 +203,14 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (servers.length > 0) { scannedMcpServers.servers = {}; for (const [serverName, config] of servers) { - scannedMcpServers.servers[serverName] = this.sanitizeServer(config, scannedWorkspaceFolderMcpServers.sandbox); + const serverConfig = this.sanitizeServer(config); + scannedMcpServers.servers[serverName] = serverConfig; } } return scannedMcpServers; } - private sanitizeServer(serverOrConfig: IOldScannedMcpServer | Mutable, sandbox?: IMcpSandboxConfiguration): IMcpServerConfiguration { + private sanitizeServer(serverOrConfig: IOldScannedMcpServer | Mutable): IMcpServerConfiguration { let server: IMcpServerConfiguration; if ((serverOrConfig).config) { const oldScannedMcpServer = serverOrConfig; @@ -218,11 +226,6 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (server.type === undefined || (server.type !== McpServerType.REMOTE && server.type !== McpServerType.LOCAL)) { (>server).type = (server).command ? McpServerType.LOCAL : McpServerType.REMOTE; } - - if (sandbox && server.type === McpServerType.LOCAL && !(server as IMcpStdioServerConfiguration).sandbox && server.sandboxEnabled) { - (>server).sandbox = sandbox; - } - return server; } diff --git a/src/vs/platform/mcp/common/modelContextProtocolApps.ts b/src/vs/platform/mcp/common/modelContextProtocolApps.ts index 4569e8f25ac8d..86b891514e213 100644 --- a/src/vs/platform/mcp/common/modelContextProtocolApps.ts +++ b/src/vs/platform/mcp/common/modelContextProtocolApps.ts @@ -17,6 +17,7 @@ export namespace McpApps { | MCP.ReadResourceRequest | MCP.PingRequest | (McpUiOpenLinkRequest & MCP.JSONRPCRequest) + | (McpUiDownloadFileRequest & MCP.JSONRPCRequest) | (McpUiUpdateModelContextRequest & MCP.JSONRPCRequest) | (McpUiMessageRequest & MCP.JSONRPCRequest) | (McpUiRequestDisplayModeRequest & MCP.JSONRPCRequest) @@ -37,6 +38,7 @@ export namespace McpApps { | McpApps.McpUiInitializeResult | McpUiMessageResult | McpUiOpenLinkResult + | McpUiDownloadFileResult | McpUiRequestDisplayModeResult; export type HostNotification = @@ -223,6 +225,33 @@ export namespace McpApps { [key: string]: unknown; } + /** + * @description Request to download one or more files through the host. + * Uses standard MCP resource types: EmbeddedResource for inline content + * and ResourceLink for references the host resolves via resources/read. + */ + export interface McpUiDownloadFileRequest { + method: "ui/download-file"; + params: { + /** @description Resources to download, either inline or as links for the host to resolve. */ + contents: (MCP.EmbeddedResource | MCP.ResourceLink)[]; + }; + } + + /** + * @description Result from a download file request. + * @see {@link McpUiDownloadFileRequest} + */ + export interface McpUiDownloadFileResult { + /** @description True if the host rejected or failed to process the download. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + /** * @description Request to send a message to the host's chat interface. * @see {@link app.App.sendMessage} for the method that sends this request @@ -528,6 +557,8 @@ export namespace McpApps { updateModelContext?: McpUiSupportedContentBlockModalities; /** @description Host supports receiving content messages (ui/message) from the View. */ message?: McpUiSupportedContentBlockModalities; + /** @description Host supports file downloads (ui/download-file) from the View. */ + downloadFile?: {}; } /** @@ -734,4 +765,6 @@ export namespace McpApps { "ui/request-display-mode"; export const UPDATE_MODEL_CONTEXT_METHOD: McpUiUpdateModelContextRequest["method"] = "ui/update-model-context"; + export const DOWNLOAD_FILE_METHOD: McpUiDownloadFileRequest["method"] = + "ui/download-file"; } diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index 0b0ce1edb0ac8..9507f2fd7ebd6 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; +import { ILoggerService } from '../../log/common/log.js'; +import { IMcpGatewayServerDescriptor, IMcpGatewayServerInfo, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; /** @@ -17,35 +18,99 @@ import { MCP } from '../common/modelContextProtocol.js'; */ export class McpGatewayChannel extends Disposable implements IServerChannel { + private readonly _onDidChangeGatewayServers = this._register(new Emitter<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>()); + private readonly _gatewayDisposables = this._register(new DisposableMap()); + /** Tracks which gateways belong to which client for cleanup on disconnect */ + private readonly _clientGateways = new Map>(); + constructor( private readonly _ipcServer: IPCServer, - @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService + @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService, + @ILoggerService private readonly _loggerService: ILoggerService, ) { super(); - this._register(_ipcServer.onDidRemoveConnection(c => mcpGatewayService.disposeGatewaysForClient(c.ctx))); + this._register(_ipcServer.onDidRemoveConnection(c => { + this._loggerService.getLogger('mcpGateway')?.info(`[McpGateway][Channel] Client disconnected: ${c.ctx}, cleaning up gateways`); + mcpGatewayService.disposeGatewaysForClient(c.ctx); + + // Clean up per-gateway change-event forwarders for this client + const gatewaysForClient = this._clientGateways.get(c.ctx); + if (gatewaysForClient) { + for (const gatewayId of gatewaysForClient) { + this._gatewayDisposables.deleteAndDispose(gatewayId); + } + this._clientGateways.delete(c.ctx); + } + })); } - listen(_ctx: TContext, _event: string): Event { - throw new Error('Invalid listen'); + listen(_ctx: TContext, event: string): Event { + if (event === 'onDidChangeGatewayServers') { + return this._onDidChangeGatewayServers.event as Event; + } + throw new Error(`Invalid listen: ${event}`); } async call(ctx: TContext, command: string, args?: unknown): Promise { + const logger = this._loggerService.getLogger('mcpGateway'); + logger?.debug(`[McpGateway][Channel] IPC call: ${command} from client ${ctx}`); + switch (command) { case 'createGateway': { const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); + + // Fetch initial server list before creating the gateway (IPC is async, but the invoker interface is sync) + let currentServers = await brokerChannel.call('listServers'); + const onDidChangeServersListener = brokerChannel.listen('onDidChangeServers'); + const result = await this.mcpGatewayService.createGateway(ctx, { + onDidChangeServers: Event.map(onDidChangeServersListener, servers => { + currentServers = servers; + return servers; + }), onDidChangeTools: brokerChannel.listen('onDidChangeTools'), onDidChangeResources: brokerChannel.listen('onDidChangeResources'), - listTools: () => brokerChannel.call('listTools'), - callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), - listResources: () => brokerChannel.call('listResources'), - readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), - listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), + listServers: () => currentServers, + listToolsForServer: serverId => brokerChannel.call('listToolsForServer', { serverId }), + callToolForServer: (serverId, name, callArgs) => brokerChannel.call('callToolForServer', { serverId, name, args: callArgs }), + listResourcesForServer: serverId => brokerChannel.call('listResourcesForServer', { serverId }), + readResourceForServer: (serverId, uri) => brokerChannel.call('readResourceForServer', { serverId, uri }), + listResourceTemplatesForServer: serverId => brokerChannel.call('listResourceTemplatesForServer', { serverId }), }); - return result as T; + // Forward server change events via IPC + const gatewayStore = new DisposableStore(); + gatewayStore.add(result.onDidChangeServers(servers => { + this._onDidChangeGatewayServers.fire({ gatewayId: result.gatewayId, servers }); + })); + this._gatewayDisposables.set(result.gatewayId, gatewayStore); + + // Track client → gateway for disconnect cleanup + let gatewaysForClient = this._clientGateways.get(ctx); + if (!gatewaysForClient) { + gatewaysForClient = new Set(); + this._clientGateways.set(ctx, gatewaysForClient); + } + gatewaysForClient.add(result.gatewayId); + + logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} with ${result.servers.length} server(s) for client ${ctx}`); + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return { gatewayId: result.gatewayId, servers: result.servers } as T; } case 'disposeGateway': { - await this.mcpGatewayService.disposeGateway(args as string); + const gatewayId = args as string; + logger?.info(`[McpGateway][Channel] Disposing gateway: ${gatewayId} for client ${ctx}`); + this._gatewayDisposables.deleteAndDispose(gatewayId); + + // Remove from client tracking + const gatewaysForClient = this._clientGateways.get(ctx); + if (gatewaysForClient) { + gatewaysForClient.delete(gatewayId); + if (gatewaysForClient.size === 0) { + this._clientGateways.delete(ctx); + } + } + + await this.mcpGatewayService.disposeGateway(gatewayId); return undefined as T; } } diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index 8225b3fffe8c3..e00be3def1bdd 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -5,12 +5,13 @@ import type * as http from 'http'; import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter } from '../../../base/common/event.js'; import { JsonRpcMessage, JsonRpcProtocol } from '../../../base/common/jsonRpcProtocol.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; -import { IMcpGatewayInfo, IMcpGatewayService, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; +import { ILogger, ILoggerService } from '../../log/common/log.js'; +import { IMcpGatewayInfo, IMcpGatewayServerDescriptor, IMcpGatewayServerInfo, IMcpGatewayService, IMcpGatewaySingleServerInvoker, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; import { isInitializeMessage, McpGatewaySession } from './mcpGatewaySession.js'; /** @@ -24,15 +25,25 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService private _server: http.Server | undefined; private _port: number | undefined; - private readonly _gateways = new Map(); + /** All active routes keyed by their route UUID */ + private readonly _routes = new Map(); + /** Maps gatewayId → set of route UUIDs belonging to that gateway */ + private readonly _gatewayRoutes = new Map>(); + /** Maps gatewayId → serverId → routeId for reverse lookup */ + private readonly _gatewayServerRoutes = new Map>(); /** Maps gatewayId to clientId for tracking ownership */ private readonly _gatewayToClient = new Map(); + /** Per-gateway disposables (e.g. event listeners) */ + private readonly _gatewayDisposables = new Map(); private _serverStartPromise: Promise | undefined; + private readonly _logger: ILogger; constructor( - @ILogService private readonly _logService: ILogService, + @ILoggerService loggerService: ILoggerService, ) { super(); + this._logger = this._register(loggerService.createLogger('mcpGateway', { name: 'MCP Gateway', logLevel: 'always' })); + this._logger.info('[McpGatewayService] Initialized'); } async createGateway(clientId: unknown, toolInvoker?: IMcpGatewayToolInvoker): Promise { @@ -43,47 +54,185 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService throw new Error('[McpGatewayService] Server failed to start, port is undefined'); } - // Generate a secure random ID for the gateway route - const gatewayId = generateUuid(); - - // Create the gateway route if (!toolInvoker) { throw new Error('[McpGatewayService] Tool invoker is required to create gateway'); } - const gateway = new McpGatewayRoute(gatewayId, this._logService, toolInvoker); - this._gateways.set(gatewayId, gateway); + const gatewayId = generateUuid(); + const routeIds = new Set(); + const serverRouteMap = new Map(); + this._gatewayRoutes.set(gatewayId, routeIds); + this._gatewayServerRoutes.set(gatewayId, serverRouteMap); + + const disposables = new DisposableStore(); + this._gatewayDisposables.set(gatewayId, disposables); + + try { + // Create initial server routes + const serverDescriptors = toolInvoker.listServers(); + const servers: IMcpGatewayServerInfo[] = []; + for (const descriptor of serverDescriptors) { + const serverInfo = this._createRouteForServer(gatewayId, descriptor.id, descriptor.label, toolInvoker, routeIds, serverRouteMap); + servers.push(serverInfo); + } + + // Track client ownership + if (clientId) { + this._gatewayToClient.set(gatewayId, clientId); + this._logger.info(`[McpGatewayService] Created gateway ${gatewayId} with ${servers.length} server(s) for client ${clientId}`); + } else { + this._logger.warn(`[McpGatewayService] Created gateway ${gatewayId} with ${servers.length} server(s) without client tracking`); + } + + // Listen for server changes to dynamically add/remove routes + const onDidChangeServers = disposables.add(new Emitter()); + disposables.add(toolInvoker.onDidChangeServers(newDescriptors => { + this._refreshGatewayServers(gatewayId, newDescriptors, toolInvoker, routeIds, serverRouteMap, onDidChangeServers); + })); + + return { + servers, + onDidChangeServers: onDidChangeServers.event, + gatewayId, + }; + } catch (error) { + // Clean up partially-created state on failure + this._cleanupGateway(gatewayId); + throw error; + } + } + + private _refreshGatewayServers( + gatewayId: string, + newDescriptors: readonly IMcpGatewayServerDescriptor[], + toolInvoker: IMcpGatewayToolInvoker, + routeIds: Set, + serverRouteMap: Map, + onDidChangeServers: Emitter, + ): void { + // Bail out if the gateway has been disposed + if (!this._gatewayRoutes.has(gatewayId)) { + return; + } - // Track client ownership if clientId provided (for cleanup on disconnect) - if (clientId) { - this._gatewayToClient.set(gatewayId, clientId); - this._logService.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`); - } else { - this._logService.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + const newServerIds = new Set(newDescriptors.map(d => d.id)); + const existingServerIds = new Set(serverRouteMap.keys()); + + // Remove routes for servers that are gone + for (const serverId of existingServerIds) { + if (!newServerIds.has(serverId)) { + const routeId = serverRouteMap.get(serverId); + if (routeId) { + this._disposeRoute(routeId); + routeIds.delete(routeId); + serverRouteMap.delete(serverId); + } + } } - const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + // Add routes for new servers, and update labels for existing ones. + for (const descriptor of newDescriptors) { + if (!existingServerIds.has(descriptor.id)) { + this._createRouteForServer(gatewayId, descriptor.id, descriptor.label, toolInvoker, routeIds, serverRouteMap); + continue; + } + + const routeId = serverRouteMap.get(descriptor.id); + const route = routeId ? this._routes.get(routeId) : undefined; + if (route && route.label !== descriptor.label) { + route.label = descriptor.label; + } + } - return { - address, - gatewayId, + const updatedServers = this._getGatewayServers(gatewayId); + this._logger.info(`[McpGatewayService] Gateway ${gatewayId} servers changed: ${updatedServers.length} server(s)`); + onDidChangeServers.fire(updatedServers); + } + + private _cleanupGateway(gatewayId: string): void { + const routeIds = this._gatewayRoutes.get(gatewayId); + if (routeIds) { + for (const routeId of routeIds) { + this._disposeRoute(routeId); + } + } + this._gatewayRoutes.delete(gatewayId); + this._gatewayServerRoutes.delete(gatewayId); + this._gatewayToClient.delete(gatewayId); + this._gatewayDisposables.get(gatewayId)?.dispose(); + this._gatewayDisposables.delete(gatewayId); + } + + private _createRouteForServer( + gatewayId: string, + serverId: string, + label: string, + toolInvoker: IMcpGatewayToolInvoker, + routeIds: Set, + serverRouteMap: Map, + ): IMcpGatewayServerInfo { + const routeId = generateUuid(); + + // Create a single-server invoker that delegates to the aggregating invoker + const singleServerInvoker: IMcpGatewaySingleServerInvoker = { + onDidChangeTools: toolInvoker.onDidChangeTools, + onDidChangeResources: toolInvoker.onDidChangeResources, + listTools: () => toolInvoker.listToolsForServer(serverId), + callTool: (name, args) => toolInvoker.callToolForServer(serverId, name, args), + listResources: () => toolInvoker.listResourcesForServer(serverId), + readResource: uri => toolInvoker.readResourceForServer(serverId, uri), + listResourceTemplates: () => toolInvoker.listResourceTemplatesForServer(serverId), }; + + const route = new McpGatewayRoute(routeId, this._logger, singleServerInvoker, label); + this._routes.set(routeId, route); + routeIds.add(routeId); + serverRouteMap.set(serverId, routeId); + + const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${routeId}`); + this._logger.info(`[McpGatewayService] Created route ${routeId} for server '${label}' (${serverId}) at ${address}`); + + return { label, address }; + } + + private _getGatewayServers(gatewayId: string): IMcpGatewayServerInfo[] { + const serverRouteMap = this._gatewayServerRoutes.get(gatewayId); + if (!serverRouteMap) { + return []; + } + const servers: IMcpGatewayServerInfo[] = []; + for (const [_serverId, routeId] of serverRouteMap) { + const route = this._routes.get(routeId); + if (route) { + servers.push({ + label: route.label, + address: URI.parse(`http://127.0.0.1:${this._port}/gateway/${routeId}`), + }); + } + } + return servers; + } + + private _disposeRoute(routeId: string): void { + const route = this._routes.get(routeId); + if (route) { + route.dispose(); + this._routes.delete(routeId); + this._logger.info(`[McpGatewayService] Disposed route: ${routeId}`); + } } async disposeGateway(gatewayId: string): Promise { - const gateway = this._gateways.get(gatewayId); - if (!gateway) { - this._logService.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); + if (!this._gatewayRoutes.has(gatewayId)) { + this._logger.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); return; } - gateway.dispose(); - this._gateways.delete(gatewayId); - this._gatewayToClient.delete(gatewayId); - this._logService.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`); + this._cleanupGateway(gatewayId); + this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining routes: ${this._routes.size})`); - // If no more gateways, shut down the server - if (this._gateways.size === 0) { + // If no more routes, shut down the server + if (this._routes.size === 0) { this._stopServer(); } } @@ -98,16 +247,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } if (gatewaysToDispose.length > 0) { - this._logService.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); + this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); for (const gatewayId of gatewaysToDispose) { - this._gateways.get(gatewayId)?.dispose(); - this._gateways.delete(gatewayId); - this._gatewayToClient.delete(gatewayId); + this._cleanupGateway(gatewayId); } - // If no more gateways, shut down the server - if (this._gateways.size === 0) { + // If no more routes, shut down the server + if (this._routes.size === 0) { this._stopServer(); } } @@ -156,19 +303,19 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } clearTimeout(portTimeout); - this._logService.info(`[McpGatewayService] Server started on port ${this._port}`); + this._logger.info(`[McpGatewayService] Server started on port ${this._port}`); deferredPromise.complete(); }); this._server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { - this._logService.warn('[McpGatewayService] Port in use, retrying with random port...'); + this._logger.warn('[McpGatewayService] Port in use, retrying with random port...'); // Try with a random port this._server!.listen(0, '127.0.0.1'); return; } clearTimeout(portTimeout); - this._logService.error(`[McpGatewayService] Server error: ${err}`); + this._logger.error(`[McpGatewayService] Server error: ${err}`); deferredPromise.error(err); }); @@ -183,13 +330,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService return; } - this._logService.info('[McpGatewayService] Stopping server (no more gateways)'); + this._logger.info('[McpGatewayService] Stopping server (no more routes)'); this._server.close(err => { if (err) { - this._logService.error(`[McpGatewayService] Error closing server: ${err}`); + this._logger.error(`[McpGatewayService] Error closing server: ${err}`); } else { - this._logService.info('[McpGatewayService] Server stopped'); + this._logger.info('[McpGatewayService] Server stopped'); } }); @@ -201,34 +348,45 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const url = new URL(req.url!, `http://${req.headers.host}`); const pathParts = url.pathname.split('/').filter(Boolean); - // Expected path: /gateway/{gatewayId} + this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active routes: ${this._routes.size})`); + + // Expected path: /gateway/{routeId} if (pathParts.length >= 2 && pathParts[0] === 'gateway') { - const gatewayId = pathParts[1]; - const gateway = this._gateways.get(gatewayId); + const routeId = pathParts[1]; + const route = this._routes.get(routeId); - if (gateway) { - gateway.handleRequest(req, res); + if (route) { + route.handleRequest(req, res); return; } } // Not found + this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: route not found`); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Gateway not found' })); } override dispose(): void { + this._logger.info(`[McpGatewayService] Disposing service (routes: ${this._routes.size})`); this._stopServer(); - for (const gateway of this._gateways.values()) { - gateway.dispose(); + for (const route of this._routes.values()) { + route.dispose(); + } + this._routes.clear(); + this._gatewayRoutes.clear(); + this._gatewayServerRoutes.clear(); + this._gatewayToClient.clear(); + for (const disposables of this._gatewayDisposables.values()) { + disposables.dispose(); } - this._gateways.clear(); + this._gatewayDisposables.clear(); super.dispose(); } } /** - * Represents a single MCP gateway route. + * Represents a single MCP gateway route for one MCP server. */ class McpGatewayRoute extends Disposable { private readonly _sessions = new Map(); @@ -236,14 +394,17 @@ class McpGatewayRoute extends Disposable { private static readonly SessionHeaderName = 'mcp-session-id'; constructor( - public readonly gatewayId: string, - private readonly _logService: ILogService, - private readonly _toolInvoker: IMcpGatewayToolInvoker, + public readonly routeId: string, + private readonly _logger: ILogger, + private readonly _serverInvoker: IMcpGatewaySingleServerInvoker, + public label: string = '', ) { super(); } handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + this._logger.debug(`[McpGateway][route ${this.routeId}] ${req.method} request (sessions: ${this._sessions.size})`); + if (req.method === 'POST') { void this._handlePost(req, res); return; @@ -263,6 +424,7 @@ class McpGatewayRoute extends Disposable { } public override dispose(): void { + this._logger.info(`[McpGateway][route ${this.routeId}] Disposing route (sessions: ${this._sessions.size})`); for (const session of this._sessions.values()) { session.dispose(); } @@ -283,6 +445,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.routeId}] Deleting session ${sessionId}`); session.dispose(); this._sessions.delete(sessionId); res.writeHead(204); @@ -302,6 +465,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.routeId}] SSE connection requested for session ${sessionId}`); session.attachSseClient(req, res); } @@ -312,10 +476,13 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.debug(`[McpGateway][route ${this.routeId}] Handling POST`); + let message: JsonRpcMessage | JsonRpcMessage[]; try { message = JSON.parse(body) as JsonRpcMessage | JsonRpcMessage[]; } catch (error) { + this._logger.warn(`[McpGateway][route ${this.routeId}] JSON parse error: ${error instanceof Error ? error.message : String(error)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(JsonRpcProtocol.createParseError('Parse error', error instanceof Error ? error.message : String(error)))); return; @@ -336,15 +503,18 @@ class McpGatewayRoute extends Disposable { }; if (responses.length === 0) { + this._logger.debug(`[McpGateway][route ${this.routeId}] POST response: 202 (no content)`); res.writeHead(202, headers); res.end(); return; } + const responseBody = JSON.stringify(Array.isArray(message) ? responses : responses[0]); + this._logger.debug(`[McpGateway][route ${this.routeId}] POST response: 200, body: ${responseBody}`); res.writeHead(200, headers); - res.end(JSON.stringify(Array.isArray(message) ? responses : responses[0])); + res.end(responseBody); } catch (error) { - this._logService.error('[McpGatewayService] Failed handling gateway request', error); + this._logger.error('[McpGatewayService] Failed handling gateway request', error); this._respondHttpError(res, 500, 'Internal server error'); } } @@ -353,6 +523,7 @@ class McpGatewayRoute extends Disposable { if (headerSessionId) { const existing = this._sessions.get(headerSessionId); if (!existing) { + this._logger.warn(`[McpGateway][route ${this.routeId}] Session not found: ${headerSessionId}`); this._respondHttpError(res, 404, 'Session not found'); return undefined; } @@ -366,14 +537,16 @@ class McpGatewayRoute extends Disposable { } const sessionId = generateUuid(); - const session = new McpGatewaySession(sessionId, this._logService, () => { + this._logger.info(`[McpGateway][route ${this.routeId}] Creating new session ${sessionId}`); + const session = new McpGatewaySession(sessionId, this._logger, () => { this._sessions.delete(sessionId); - }, this._toolInvoker); + }, this._serverInvoker); this._sessions.set(sessionId, session); return session; } private _respondHttpError(res: http.ServerResponse, statusCode: number, error: string): void { + this._logger.debug(`[McpGateway][route ${this.routeId}] HTTP error response: ${statusCode} ${error}`); res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: statusCode, message: error } } satisfies JsonRpcMessage)); } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index 836d6571e3b5b..067899cb32f6b 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -6,82 +6,37 @@ import type * as http from 'http'; import { IJsonRpcNotification, IJsonRpcRequest, - isJsonRpcNotification, isJsonRpcResponse, JsonRpcError, JsonRpcMessage, JsonRpcProtocol + isJsonRpcNotification, isJsonRpcResponse, JsonRpcError, JsonRpcMessage, JsonRpcProtocol, JsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { hasKey } from '../../../base/common/types.js'; -import { ILogService } from '../../log/common/log.js'; -import { IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; +import { ILogger } from '../../log/common/log.js'; +import { IMcpGatewaySingleServerInvoker } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; const MCP_LATEST_PROTOCOL_VERSION = '2025-11-25'; +const MCP_SUPPORTED_PROTOCOL_VERSIONS = [ + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', + '2024-10-07', +]; const MCP_INVALID_REQUEST = -32600; const MCP_METHOD_NOT_FOUND = -32601; const MCP_INVALID_PARAMS = -32602; -const GATEWAY_URI_AUTHORITY_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)(.*)/; - -/** - * Encodes a resource URI for the gateway by appending `-{serverIndex}` to the authority. - * This namespaces resources from different MCP servers served through the same gateway. - */ -export function encodeGatewayResourceUri(uri: string, serverIndex: number): string { - const match = uri.match(GATEWAY_URI_AUTHORITY_RE); - if (!match) { - return uri; - } - const [, prefix, authority, rest] = match; - return `${prefix}${authority}-${serverIndex}${rest}`; -} - -/** - * Decodes a gateway-encoded resource URI, extracting the server index and original URI. - */ -export function decodeGatewayResourceUri(uri: string): { serverIndex: number; originalUri: string } { - const match = uri.match(GATEWAY_URI_AUTHORITY_RE); - if (!match) { - throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid resource URI: ${uri}`); - } - const [, prefix, authority, rest] = match; - const suffixMatch = authority.match(/^(.*)-([0-9]+)$/); - if (!suffixMatch) { - throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid gateway resource URI (no server index): ${uri}`); - } - const [, originalAuthority, indexStr] = suffixMatch; - return { - serverIndex: parseInt(indexStr, 10), - originalUri: `${prefix}${originalAuthority}${rest}`, - }; -} - -function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: number): MCP.ContentBlock[] { - return content.map(block => { - if (block.type === 'resource_link') { - return { ...block, uri: encodeGatewayResourceUri(block.uri, serverIndex) }; - } - if (block.type === 'resource') { - return { - ...block, - resource: { ...block.resource, uri: encodeGatewayResourceUri(block.resource.uri, serverIndex) }, - }; - } - return block; - }); -} - export class McpGatewaySession extends Disposable { private readonly _rpc: JsonRpcProtocol; private readonly _sseClients = new Set(); - private readonly _pendingResponses: JsonRpcMessage[] = []; - private _isCollectingPostResponses = false; private _lastEventId = 0; private _isInitialized = false; constructor( public readonly id: string, - private readonly _logService: ILogService, + private readonly _logService: ILogger, private readonly _onDidDispose: () => void, - private readonly _toolInvoker: IMcpGatewayToolInvoker, + private readonly _serverInvoker: IMcpGatewaySingleServerInvoker, ) { super(); @@ -93,19 +48,21 @@ export class McpGatewaySession extends Disposable { } )); - this._register(this._toolInvoker.onDidChangeTools(() => { + this._register(this._serverInvoker.onDidChangeTools(() => { if (!this._isInitialized) { return; } + this._logService.info(`[McpGateway][session ${this.id}] Tools changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); - this._register(this._toolInvoker.onDidChangeResources(() => { + this._register(this._serverInvoker.onDidChangeResources(() => { if (!this._isInitialized) { return; } + this._logService.info(`[McpGateway][session ${this.id}] Resources changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); })); } @@ -119,25 +76,20 @@ export class McpGatewaySession extends Disposable { res.write(': connected\n\n'); this._sseClients.add(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client attached (total: ${this._sseClients.size})`); res.on('close', () => { this._sseClients.delete(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client detached (total: ${this._sseClients.size})`); }); } - public async handleIncoming(message: JsonRpcMessage | JsonRpcMessage[]): Promise { - this._pendingResponses.length = 0; - this._isCollectingPostResponses = true; - try { - await this._rpc.handleMessage(message); - return [...this._pendingResponses]; - } finally { - this._isCollectingPostResponses = false; - this._pendingResponses.length = 0; - } + public async handleIncoming(message: JsonRpcMessage | JsonRpcMessage[]): Promise { + return this._rpc.handleMessage(message); } public override dispose(): void { + this._logService.info(`[McpGateway][session ${this.id}] Disposing session (SSE clients: ${this._sseClients.size})`); for (const client of this._sseClients) { if (!client.destroyed) { client.end(); @@ -150,13 +102,12 @@ export class McpGatewaySession extends Disposable { private _handleOutgoingMessage(message: JsonRpcMessage): void { if (isJsonRpcResponse(message)) { - if (this._isCollectingPostResponses) { - this._pendingResponses.push(message); - } + this._logService.debug(`[McpGateway][session ${this.id}] --> response: ${JSON.stringify(message)}`); return; } if (isJsonRpcNotification(message)) { + this._logService.debug(`[McpGateway][session ${this.id}] --> notification: ${(message as IJsonRpcNotification).method}`); this._broadcastSse(message); return; } @@ -166,11 +117,13 @@ export class McpGatewaySession extends Disposable { private _broadcastSse(message: JsonRpcMessage): void { if (this._sseClients.size === 0) { + this._logService.debug(`[McpGateway][session ${this.id}] No SSE clients to broadcast to, dropping message`); return; } const payload = JSON.stringify(message); const eventId = String(++this._lastEventId); + this._logService.debug(`[McpGateway][session ${this.id}] Broadcasting SSE event id=${eventId} to ${this._sseClients.size}`); const lines = payload.split(/\r?\n/g); const data = [ `id: ${eventId}`, @@ -191,11 +144,14 @@ export class McpGatewaySession extends Disposable { } private async _handleRequest(request: IJsonRpcRequest): Promise { + this._logService.debug(`[McpGateway][session ${this.id}] <-- request: ${request.method} (id=${String(request.id)})`); + if (request.method === 'initialize') { - return this._handleInitialize(); + return this._handleInitialize(request); } if (!this._isInitialized) { + this._logService.warn(`[McpGateway][session ${this.id}] Rejected request '${request.method}': session not initialized`); throw new JsonRpcError(MCP_INVALID_REQUEST, 'Session is not initialized'); } @@ -213,21 +169,37 @@ export class McpGatewaySession extends Disposable { case 'resources/templates/list': return this._handleListResourceTemplates(); default: + this._logService.warn(`[McpGateway][session ${this.id}] Unknown method: ${request.method}`); throw new JsonRpcError(MCP_METHOD_NOT_FOUND, `Method not found: ${request.method}`); } } private _handleNotification(notification: IJsonRpcNotification): void { + this._logService.debug(`[McpGateway][session ${this.id}] <-- notification: ${notification.method}`); + if (notification.method === 'notifications/initialized') { this._isInitialized = true; + this._logService.info(`[McpGateway][session ${this.id}] Session initialized`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); } } - private _handleInitialize(): MCP.InitializeResult { + private _handleInitialize(request: IJsonRpcRequest): MCP.InitializeResult { + const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; + const clientVersion = typeof params?.protocolVersion === 'string' ? params.protocolVersion : undefined; + const clientInfo = params?.clientInfo as { name?: string; version?: string } | undefined; + const negotiatedVersion = clientVersion && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion) + ? clientVersion + : MCP_LATEST_PROTOCOL_VERSION; + + this._logService.info(`[McpGateway] Initialize: client=${clientInfo?.name ?? 'unknown'}/${clientInfo?.version ?? '?'}, clientProtocol=${clientVersion ?? '(none)'}, negotiated=${negotiatedVersion}`); + if (clientVersion && clientVersion !== negotiatedVersion) { + this._logService.warn(`[McpGateway] Client requested unsupported protocol version '${clientVersion}', falling back to '${negotiatedVersion}'`); + } + return { - protocolVersion: MCP_LATEST_PROTOCOL_VERSION, + protocolVersion: negotiatedVersion, capabilities: { tools: { listChanged: true, @@ -257,35 +229,28 @@ export class McpGatewaySession extends Disposable { ? params.arguments as Record : {}; + this._logService.debug(`[McpGateway][session ${this.id}] Calling tool '${params.name}' with args: ${JSON.stringify(argumentsValue)}`); + try { - const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); - return { - ...result, - content: encodeResourceUrisInContent(result.content, serverIndex), - }; + const result = await this._serverInvoker.callTool(params.name, argumentsValue); + this._logService.debug(`[McpGateway][session ${this.id}] Tool '${params.name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); + return result; } catch (error) { - this._logService.error('[McpGatewayService] Tool call invocation failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Tool '${params.name}' invocation failed`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } - private _handleListTools(): unknown { - return this._toolInvoker.listTools() - .then(tools => ({ tools })); + private async _handleListTools(): Promise { + const tools = await this._serverInvoker.listTools(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); + return { tools: tools as MCP.Tool[] }; } private async _handleListResources(): Promise { - const serverResults = await this._toolInvoker.listResources(); - const allResources: MCP.Resource[] = []; - for (const { serverIndex, resources } of serverResults) { - for (const resource of resources) { - allResources.push({ - ...resource, - uri: encodeGatewayResourceUri(resource.uri, serverIndex), - }); - } - } - return { resources: allResources }; + const resources = await this._serverInvoker.listResources(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${resources.length} resource(s)`); + return { resources: resources as MCP.Resource[] }; } private async _handleReadResource(request: IJsonRpcRequest): Promise { @@ -294,33 +259,21 @@ export class McpGatewaySession extends Disposable { throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing resource URI'); } - const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${params.uri}'`); try { - const result = await this._toolInvoker.readResource(serverIndex, originalUri); - return { - contents: result.contents.map(content => ({ - ...content, - uri: encodeGatewayResourceUri(content.uri, serverIndex), - })), - }; + const result = await this._serverInvoker.readResource(params.uri); + this._logService.debug(`[McpGateway][session ${this.id}] Resource read returned ${result.contents.length} content(s)`); + return result; } catch (error) { - this._logService.error('[McpGatewayService] Resource read failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${params.uri}'`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } private async _handleListResourceTemplates(): Promise { - const serverResults = await this._toolInvoker.listResourceTemplates(); - const allTemplates: MCP.ResourceTemplate[] = []; - for (const { serverIndex, resourceTemplates } of serverResults) { - for (const template of resourceTemplates) { - allTemplates.push({ - ...template, - uriTemplate: encodeGatewayResourceUri(template.uriTemplate, serverIndex), - }); - } - } - return { resourceTemplates: allTemplates }; + const resourceTemplates = await this._serverInvoker.listResourceTemplates(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${resourceTemplates.length} resource template(s)`); + return { resourceTemplates: resourceTemplates as MCP.ResourceTemplate[] }; } } diff --git a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts index 78ee329965290..7245ffd376bdd 100644 --- a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts @@ -4,14 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AbstractCommonMcpManagementService } from '../../common/mcpManagementService.js'; -import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, ILocalMcpServer, InstallOptions, RegistryType, TransportType, UninstallOptions } from '../../common/mcpManagement.js'; -import { McpServerType, McpServerVariableType, IMcpServerVariable } from '../../common/mcpPlatformTypes.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { AbstractCommonMcpManagementService, AbstractMcpResourceManagementService } from '../../common/mcpManagementService.js'; +import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, ILocalMcpServer, IMcpGalleryService, InstallOptions, RegistryType, TransportType, UninstallOptions } from '../../common/mcpManagement.js'; +import { IMcpSandboxConfiguration, McpServerType, McpServerVariableType, IMcpServerConfiguration, IMcpServerVariable } from '../../common/mcpPlatformTypes.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import { ConfigurationTarget } from '../../../configuration/common/configuration.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; +import { McpResourceScannerService } from '../../common/mcpResourceScannerService.js'; +import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js'; class TestMcpManagementService extends AbstractCommonMcpManagementService { @@ -42,6 +50,44 @@ class TestMcpManagementService extends AbstractCommonMcpManagementService { } } +class TestMcpResourceManagementService extends AbstractMcpResourceManagementService { + constructor(mcpResource: URI, fileService: FileService, uriIdentityService: UriIdentityService, mcpResourceScannerService: McpResourceScannerService) { + super( + mcpResource, + ConfigurationTarget.USER, + {} as IMcpGalleryService, + fileService, + uriIdentityService, + new NullLogService(), + mcpResourceScannerService, + ); + } + + public reload(): Promise { + return this.updateLocal(); + } + + override canInstall(_server: IGalleryMcpServer | IInstallableMcpServer): true | IMarkdownString { + throw new Error('Not supported'); + } + + protected override getLocalServerInfo(_name: string, _mcpServerConfig: IMcpServerConfiguration) { + return Promise.resolve(undefined); + } + + protected override installFromUri(_uri: URI): Promise { + throw new Error('Not supported'); + } + + override installFromGallery(_server: IGalleryMcpServer, _options?: InstallOptions): Promise { + throw new Error('Not supported'); + } + + override updateMetadata(_local: ILocalMcpServer, _server: IGalleryMcpServer): Promise { + throw new Error('Not supported'); + } +} + suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { let service: TestMcpManagementService; @@ -1073,3 +1119,74 @@ suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { }); }); }); + +suite('McpResourceManagementService', () => { + const mcpResource = URI.from({ scheme: Schemas.inMemory, path: '/mcp.json' }); + let disposables: DisposableStore; + let fileService: FileService; + let service: TestMcpResourceManagementService; + + setup(async () => { + disposables = new DisposableStore(); + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + const uriIdentityService = disposables.add(new UriIdentityService(fileService)); + const scannerService = disposables.add(new McpResourceScannerService(fileService, uriIdentityService)); + service = disposables.add(new TestMcpResourceManagementService(mcpResource, fileService, uriIdentityService, scannerService)); + + await fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify({ + sandbox: { + network: { allowedDomains: ['example.com'] } + }, + servers: { + test: { + type: 'stdio', + command: 'node', + sandboxEnabled: true + } + } + }, null, '\t'))); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires update when root sandbox changes', async () => { + const initial = await service.getInstalled(); + assert.strictEqual(initial.length, 1); + assert.deepStrictEqual(initial[0].rootSandbox, { + network: { allowedDomains: ['example.com'] } + }); + + let updateCount = 0; + const updatePromise = new Promise(resolve => disposables.add(service.onDidUpdateMcpServers(e => { + assert.strictEqual(e.length, 1); + updateCount++; + resolve(); + }))); + + const updatedSandbox: IMcpSandboxConfiguration = { + network: { allowedDomains: ['changed.example.com'] } + }; + + await fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify({ + sandbox: updatedSandbox, + servers: { + test: { + type: 'stdio', + command: 'node', + sandboxEnabled: true + } + } + }, null, '\t'))); + await service.reload(); + await updatePromise; + const updated = await service.getInstalled(); + + assert.strictEqual(updateCount, 1); + assert.deepStrictEqual(updated[0].rootSandbox, updatedSandbox); + }); +}); diff --git a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts index 98712bb96810a..029e51118b715 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -11,7 +11,7 @@ import { IJsonRpcErrorResponse, IJsonRpcSuccessResponse } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { MCP } from '../../common/modelContextProtocol.js'; -import { decodeGatewayResourceUri, encodeGatewayResourceUri, McpGatewaySession } from '../../node/mcpGatewaySession.js'; +import { McpGatewaySession } from '../../node/mcpGatewaySession.js'; class TestServerResponse extends EventEmitter { public statusCode: number | undefined; @@ -73,16 +73,13 @@ suite('McpGatewaySession', () => { onDidChangeResources: onDidChangeResources.event, listTools: async () => tools, callTool: async (_name: string, args: Record) => ({ - result: { - content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] - }, - serverIndex: 0, + content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] }), - listResources: async () => [{ serverIndex: 0, resources }], - readResource: async (_serverIndex: number, _uri: string) => ({ + listResources: async () => resources, + readResource: async (_uri: string) => ({ contents: [{ uri: 'file:///test/resource.txt', text: 'hello world', mimeType: 'text/plain' }], }), - listResourceTemplates: async () => [{ serverIndex: 0, resourceTemplates: [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }] }], + listResourceTemplates: async () => [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }], } }; } @@ -112,6 +109,145 @@ suite('McpGatewaySession', () => { onDidChangeResources.dispose(); }); + test('negotiates to older protocol version when client requests it', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-1', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-03-26'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('negotiates to each supported protocol version', async () => { + const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + for (const version of supportedVersions) { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession(`session-ver-${version}`, new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: version, capabilities: {} }, + }); + + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual( + (response.result as { protocolVersion: string }).protocolVersion, + version, + `Expected server to negotiate to ${version}` + ); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + } + }); + + test('falls back to latest version for unsupported client version', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-2', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2099-01-01', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('falls back to latest version when no params provided', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-3', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('falls back to latest version when protocolVersion is not a string', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-4', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 42, + capabilities: {}, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('initialize response includes server info and capabilities', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-init-caps', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {} }, + }); + + const result = (responses[0] as IJsonRpcSuccessResponse).result as MCP.InitializeResult; + assert.deepStrictEqual(result, { + protocolVersion: '2025-03-26', + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + }, + serverInfo: { + name: 'VS Code MCP Gateway', + version: '1.0.0', + }, + }); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + test('rejects non-initialize requests before initialized notification', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-2', new NullLogService(), () => { }, invoker); @@ -241,7 +377,7 @@ suite('McpGatewaySession', () => { onDidChangeResources.dispose(); }); - test('serves resources/list with encoded URIs', async () => { + test('serves resources/list with raw URIs', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-8', new NullLogService(), () => { }, invoker); @@ -252,14 +388,14 @@ suite('McpGatewaySession', () => { const response = responses[0] as IJsonRpcSuccessResponse; const resources = (response.result as { resources: Array<{ uri: string; name: string }> }).resources; assert.strictEqual(resources.length, 1); - assert.strictEqual(resources[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(resources[0].uri, 'file:///test/resource.txt'); assert.strictEqual(resources[0].name, 'resource.txt'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); - test('serves resources/read with URI decoding and re-encoding', async () => { + test('serves resources/read with raw URIs', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-9', new NullLogService(), () => { }, invoker); @@ -270,19 +406,19 @@ suite('McpGatewaySession', () => { jsonrpc: '2.0', id: 2, method: 'resources/read', - params: { uri: 'file://-0/test/resource.txt' }, + params: { uri: 'file:///test/resource.txt' }, }); const response = responses[0] as IJsonRpcSuccessResponse; const contents = (response.result as { contents: Array<{ uri: string; text: string }> }).contents; assert.strictEqual(contents.length, 1); - assert.strictEqual(contents[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(contents[0].uri, 'file:///test/resource.txt'); assert.strictEqual(contents[0].text, 'hello world'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); - test('serves resources/templates/list with encoded URI templates', async () => { + test('serves resources/templates/list with raw URI templates', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-10', new NullLogService(), () => { }, invoker); @@ -293,71 +429,10 @@ suite('McpGatewaySession', () => { const response = responses[0] as IJsonRpcSuccessResponse; const templates = (response.result as { resourceTemplates: Array<{ uriTemplate: string; name: string }> }).resourceTemplates; assert.strictEqual(templates.length, 1); - assert.strictEqual(templates[0].uriTemplate, 'file://-0/test/{name}'); + assert.strictEqual(templates[0].uriTemplate, 'file:///test/{name}'); assert.strictEqual(templates[0].name, 'Test Template'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); }); - -suite('Gateway Resource URI encoding', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('encodes and decodes URI with authority', () => { - const encoded = encodeGatewayResourceUri('https://example.com/resource', 3); - assert.strictEqual(encoded, 'https://example.com-3/resource'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 3); - assert.strictEqual(decoded.originalUri, 'https://example.com/resource'); - }); - - test('encodes and decodes URI with empty authority', () => { - const encoded = encodeGatewayResourceUri('file:///path/to/file', 0); - assert.strictEqual(encoded, 'file://-0/path/to/file'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 0); - assert.strictEqual(decoded.originalUri, 'file:///path/to/file'); - }); - - test('encodes and decodes URI with authority containing hyphens', () => { - const encoded = encodeGatewayResourceUri('https://my-server.example.com/res', 12); - assert.strictEqual(encoded, 'https://my-server.example.com-12/res'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 12); - assert.strictEqual(decoded.originalUri, 'https://my-server.example.com/res'); - }); - - test('encodes and decodes URI with port', () => { - const encoded = encodeGatewayResourceUri('http://localhost:8080/api', 5); - assert.strictEqual(encoded, 'http://localhost:8080-5/api'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 5); - assert.strictEqual(decoded.originalUri, 'http://localhost:8080/api'); - }); - - test('encodes and decodes URI with query and fragment', () => { - const encoded = encodeGatewayResourceUri('https://example.com/resource?q=1#section', 2); - assert.strictEqual(encoded, 'https://example.com-2/resource?q=1#section'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 2); - assert.strictEqual(decoded.originalUri, 'https://example.com/resource?q=1#section'); - }); - - test('encodes and decodes custom scheme URIs', () => { - const encoded = encodeGatewayResourceUri('custom://myhost/path', 7); - assert.strictEqual(encoded, 'custom://myhost-7/path'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 7); - assert.strictEqual(decoded.originalUri, 'custom://myhost/path'); - }); - - test('returns URI unchanged if no scheme match', () => { - const encoded = encodeGatewayResourceUri('not-a-uri', 1); - assert.strictEqual(encoded, 'not-a-uri'); - }); - - test('throws on decode of URI without server index suffix', () => { - assert.throws(() => decodeGatewayResourceUri('https://example.com/resource')); - }); -}); diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index bd509719a3cce..aa48f0b90f236 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -9,7 +9,8 @@ z-index: 2550; left: 50%; -webkit-app-region: no-drag; - border-radius: 8px; + border-radius: var(--vscode-cornerRadius-xLarge); + box-shadow: var(--vscode-shadow-xl); } .quick-input-titlebar { @@ -97,6 +98,10 @@ padding: 6px 6px 4px 6px; } +.quick-input-widget .quick-input-filter .monaco-inputbox { + border-radius: var(--vscode-cornerRadius-medium); +} + .quick-input-widget.hidden-input .quick-input-header { /* reduce margins and paddings when input box hidden */ padding: 0; @@ -303,6 +308,8 @@ .quick-input-list .quick-input-list-entry .quick-input-list-separator { margin-right: 4px; + font-size: var(--vscode-bodyFontSize-xSmall); + color: var(--vscode-descriptionForeground); /* separate from keybindings or actions */ } diff --git a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts index d200f15a66ede..2731fcd47eac9 100644 --- a/src/vs/platform/quickinput/browser/pickerQuickAccess.ts +++ b/src/vs/platform/quickinput/browser/pickerQuickAccess.ts @@ -6,7 +6,7 @@ import { timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { IKeyMods, IQuickPickDidAcceptEvent, IQuickPickSeparator, IQuickPick, IQuickPickItem, IQuickInputButton } from '../common/quickInput.js'; +import { IKeyMods, IQuickPickDidAcceptEvent, IQuickPickSeparator, IQuickPick, IQuickPickItem, IQuickInputButton, isKeyModified } from '../common/quickInput.js'; import { IQuickAccessProvider, IQuickAccessProviderRunOptions } from '../common/quickAccess.js'; import { isFunction } from '../../../base/common/types.js'; @@ -51,12 +51,22 @@ export interface IPickerQuickAccessItem extends IQuickPickItem { * @param buttonIndex index of the button of the item that * was clicked. * - * @param the state of modifier keys when the button was triggered. + * @param keyMods the state of modifier keys when the button was triggered. * * @returns a value that indicates what should happen after the trigger * which can be a `Promise` for long running operations. */ trigger?(buttonIndex: number, keyMods: IKeyMods): TriggerAction | Promise; + + /** + * When set, this will be invoked instead of `accept` if modifier keys are held down. + * This is useful for actions like "attach to context" where you want to keep the picker + * open and allow multiple picks. + * + * @param keyMods the state of modifier keys when the item was accepted. + * @param event the underlying event that caused this to trigger. + */ + attach?(keyMods: IKeyMods, event: IQuickPickDidAcceptEvent): void; } export interface IPickerQuickAccessSeparator extends IQuickPickSeparator { @@ -67,7 +77,7 @@ export interface IPickerQuickAccessSeparator extends IQuickPickSeparator { * @param buttonIndex index of the button of the item that * was clicked. * - * @param the state of modifier keys when the button was triggered. + * @param keyMods the state of modifier keys when the button was triggered. * * @returns a value that indicates what should happen after the trigger * which can be a `Promise` for long running operations. @@ -337,6 +347,11 @@ export abstract class PickerQuickAccessProvider | IQuickTree | IInputBox; currentQuickPick?.accept(); }, - secondary: getSecondary(KeyCode.Enter, [], { withAltMod: true, withCtrlMod: true, withCmdMod: true }) + secondary: getSecondary(KeyCode.Enter, [], { withAltMod: true, withCtrlMod: true, withCmdMod: true, withShiftMod: true }) }); registerQuickPickCommandAndKeybindingRule( diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 8e5283ef9ad92..18db803496b45 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -60,7 +60,7 @@ export class QuickInputController extends Disposable { private readonly onDidAcceptEmitter = this._register(new Emitter()); private readonly onDidCustomEmitter = this._register(new Emitter()); private readonly onDidTriggerButtonEmitter = this._register(new Emitter()); - private keyMods: Writeable = { ctrlCmd: false, alt: false }; + private keyMods: Writeable = { ctrlCmd: false, alt: false, shift: false }; private controller: IQuickInput | null = null; get currentQuickInput() { return this.controller ?? undefined; } @@ -120,6 +120,7 @@ export class QuickInputController extends Disposable { const listener = (e: KeyboardEvent | MouseEvent) => { this.keyMods.ctrlCmd = e.ctrlKey || e.metaKey; this.keyMods.alt = e.altKey; + this.keyMods.shift = e.shiftKey; }; for (const event of [dom.EventType.KEY_DOWN, dom.EventType.KEY_UP, dom.EventType.MOUSE_DOWN]) { @@ -837,13 +838,14 @@ export class QuickInputController extends Disposable { } } - async accept(keyMods: IKeyMods = { alt: false, ctrlCmd: false }) { + async accept(keyMods: IKeyMods = { alt: false, ctrlCmd: false, shift: false }) { // When accepting the item programmatically, it is important that // we update `keyMods` either from the provided set or unset it // because the accept did not happen from mouse or keyboard // interaction on the list itself this.keyMods.alt = keyMods.alt; this.keyMods.ctrlCmd = keyMods.ctrlCmd; + this.keyMods.shift = keyMods.shift; this.onDidAcceptEmitter.fire(); } @@ -922,13 +924,12 @@ export class QuickInputController extends Disposable { private updateStyles() { if (this.ui) { const { - quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, widgetShadow, + quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, } = this.styles.widget; this.ui.titleBar.style.backgroundColor = quickInputTitleBackground ?? ''; this.ui.container.style.backgroundColor = quickInputBackground ?? ''; this.ui.container.style.color = quickInputForeground ?? ''; this.ui.container.style.border = widgetBorder ? `1px solid ${widgetBorder}` : ''; - this.ui.container.style.boxShadow = widgetShadow ? `0 0 8px 2px ${widgetShadow}` : ''; this.ui.list.style(this.styles.list); this.ui.tree.tree.style(this.styles.list); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts index 8580b26401462..f71937903f37e 100644 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -321,14 +321,26 @@ class QuickInputAccessibilityProvider implements IListAccessibilityProvider implements ITreeRenderer { +abstract class BaseQuickInputListRenderer extends Disposable implements ITreeRenderer { abstract templateId: string; + private readonly _onDidDisposeFocusedElement = this._register(new Emitter()); + + /** + * This event is emitted when the renderer disposes an element that has focus. + * This allows the list to re-focus itself and prevent focus from being lost + * (potentially causing quickinput to dismiss itself) when an element is + * removed while focused. + */ + readonly onDidDisposeFocusedElement = this._onDidDisposeFocusedElement.event; + constructor( private readonly hoverDelegate: IHoverDelegate | undefined, private readonly toggleStyles: IToggleStyles, private readonly contextMenuService: IContextMenuService - ) { } + ) { + super(); + } // TODO: only do the common stuff here and have a subclass handle their specific stuff renderTemplate(container: HTMLElement): IQuickInputItemTemplateData { @@ -392,6 +404,9 @@ abstract class BaseQuickInputListRenderer implement } disposeElement(_element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { + if (dom.isAncestorOfActiveElement(data.entry)) { + this._onDidDisposeFocusedElement.fire(); + } data.toDisposeElement.clear(); data.toolBar.setActions([]); } @@ -746,8 +761,8 @@ export class QuickInputList extends Disposable { ) { super(); this._container = dom.append(this.parent, $('.quick-input-list')); - this._separatorRenderer = instantiationService.createInstance(QuickPickSeparatorElementRenderer, hoverDelegate, this.styles.toggle); - this._itemRenderer = instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate, this.styles.toggle); + this._separatorRenderer = this._register(instantiationService.createInstance(QuickPickSeparatorElementRenderer, hoverDelegate, this.styles.toggle)); + this._itemRenderer = this._register(instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate, this.styles.toggle)); this._tree = this._register(instantiationService.createInstance( WorkbenchObjectTree, 'QuickInput', @@ -786,6 +801,8 @@ export class QuickInputList extends Disposable { } )); this._tree.getHTMLElement().id = id; + this._register(this._itemRenderer.onDidDisposeFocusedElement(() => this._tree.domFocus())); + this._register(this._separatorRenderer.onDidDisposeFocusedElement(() => this._tree.domFocus())); this._registerListeners(); } diff --git a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts index 896564c768aa7..893665466a970 100644 --- a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts +++ b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts @@ -117,6 +117,9 @@ export class QuickInputTreeController extends Disposable { identityProvider: new QuickInputTreeIdentityProvider() } )); + this._register(this._renderer.onDidDisposeFocusedElement(() => { + this._tree.domFocus(); + })); this.registerCheckboxStateListeners(); this.registerOnDidChangeFocus(); } @@ -297,18 +300,22 @@ export class QuickInputTreeController extends Disposable { })); this._register(this._checkboxStateHandler.onDidChangeCheckboxState(e => { - this.updateCheckboxState(e.item, e.checked === true); + this.updateCheckboxState(e.item, e.checked === true, true); + this._tree.setFocus([e.item]); + this._tree.setSelection([e.item]); })); } - private updateCheckboxState(item: IQuickTreeItem, newState: boolean): void { + private updateCheckboxState(item: IQuickTreeItem, newState: boolean, skipItemRerender = false): void { if ((item.checked ?? false) === newState) { return; // No change } // Handle checked item item.checked = newState; - this._tree.rerender(item); + if (!skipItemRerender) { + this._tree.rerender(item); + } // Handle children of the checked item const updateSet = new Set(); diff --git a/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts b/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts index 1cc5c82159142..362c6ac18bc61 100644 --- a/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts +++ b/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts @@ -48,6 +48,16 @@ export class QuickInputTreeRenderer extends Disposable static readonly ID = 'quickInputTreeElement'; templateId = QuickInputTreeRenderer.ID; + private readonly _onDidDisposeFocusedElement = this._register(new Emitter()); + + /** + * This event is emitted when the renderer disposes an element that has focus. + * This allows the list to re-focus itself and prevent focus from being lost + * (potentially causing quickinput to dismiss itself) when an element is + * removed while focused. + */ + public readonly onDidDisposeFocusedElement = this._onDidDisposeFocusedElement.event; + constructor( private readonly _hoverDelegate: IHoverDelegate | undefined, private readonly _buttonTriggeredEmitter: Emitter>, @@ -172,6 +182,9 @@ export class QuickInputTreeRenderer extends Disposable } disposeElement(_element: ITreeNode, _index: number, templateData: IQuickTreeTemplateData, _details?: ITreeElementRenderDetails): void { + if (dom.isAncestorOfActiveElement(templateData.entry)) { + this._onDidDisposeFocusedElement.fire(); + } templateData.toDisposeElement.clear(); templateData.actionBar.setActions([]); } diff --git a/src/vs/platform/quickinput/browser/tree/quickTree.ts b/src/vs/platform/quickinput/browser/tree/quickTree.ts index e506021a05892..3c9a614694866 100644 --- a/src/vs/platform/quickinput/browser/tree/quickTree.ts +++ b/src/vs/platform/quickinput/browser/tree/quickTree.ts @@ -104,6 +104,11 @@ export class QuickTree extends QuickInput implements I this.ui.inputBox.setFocus(); } + reveal(element: T): void { + this.ui.tree.tree.reveal(element); + this.ui.tree.tree.setFocus([element]); + } + override show() { if (!this.visible) { const visibilities: Visibilities = { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9426be48e2f4a..1b15c20bd56c5 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -115,9 +115,14 @@ export interface IQuickPickSeparator { export interface IKeyMods { readonly ctrlCmd: boolean; readonly alt: boolean; + readonly shift: boolean; } -export const NO_KEY_MODS: IKeyMods = { ctrlCmd: false, alt: false }; +export function isKeyModified(keyMods: IKeyMods): boolean { + return keyMods.ctrlCmd || keyMods.alt || keyMods.shift; +} + +export const NO_KEY_MODS: IKeyMods = { ctrlCmd: false, alt: false, shift: false }; export interface IQuickNavigateConfiguration { keybindings: readonly ResolvedKeybinding[]; @@ -1172,6 +1177,12 @@ export interface IQuickTree extends IQuickInput { */ focusOnInput(): void; + /** + * Reveals and focuses a specific item in the tree. + * @param element The item to reveal and focus. + */ + reveal(element: T): void; + /** * Focus a particular item in the list. Used internally for keyboard navigation. * @param focus The focus behavior. diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index 217fef3990695..02ae5f0e506c1 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -19,7 +19,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { toDisposable } from '../../../../base/common/lifecycle.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { QuickPick } from '../../browser/quickInput.js'; -import { IQuickPickItem, ItemActivation } from '../../common/quickInput.js'; +import { IQuickPickItem, ItemActivation, isKeyModified, NO_KEY_MODS } from '../../common/quickInput.js'; import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; import { IThemeService } from '../../../theme/common/themeService.js'; import { IConfigurationService } from '../../../configuration/common/configuration.js'; @@ -66,8 +66,7 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IAccessibilityService, new TestAccessibilityService()); instantiationService.stub(IListService, store.add(new ListService())); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(ILayoutService, { activeContainer: fixture, onDidLayoutContainer: Event.None } as any); + instantiationService.stub(ILayoutService, { _serviceBrand: undefined, activeContainer: fixture, onDidLayoutContainer: Event.None }); instantiationService.stub(IContextViewService, store.add(instantiationService.createInstance(ContextViewService))); instantiationService.stub(IContextKeyService, store.add(instantiationService.createInstance(ContextKeyService))); instantiationService.stub(IKeybindingService, { @@ -279,4 +278,16 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 assert.strictEqual(activeItemsFromEvent.length, 0); assert.strictEqual(quickpick.activeItems.length, 0); }); + + test('isKeyModified - returns false when no modifiers are pressed', () => { + assert.strictEqual(isKeyModified(NO_KEY_MODS), false); + assert.strictEqual(isKeyModified({ ctrlCmd: false, alt: false, shift: false }), false); + }); + + test('isKeyModified - returns true when any modifier is pressed', () => { + assert.strictEqual(isKeyModified({ ctrlCmd: true, alt: false, shift: false }), true); + assert.strictEqual(isKeyModified({ ctrlCmd: false, alt: true, shift: false }), true); + assert.strictEqual(isKeyModified({ ctrlCmd: false, alt: false, shift: true }), true); + assert.strictEqual(isKeyModified({ ctrlCmd: true, alt: true, shift: true }), true); + }); }); diff --git a/src/vs/platform/remote/browser/browserSocketFactory.ts b/src/vs/platform/remote/browser/browserSocketFactory.ts index eafeef861a19b..d558e4eef58c1 100644 --- a/src/vs/platform/remote/browser/browserSocketFactory.ts +++ b/src/vs/platform/remote/browser/browserSocketFactory.ts @@ -43,7 +43,7 @@ export interface IWebSocket { readonly onError: Event; traceSocketEvent?(type: SocketDiagnosticsEventType, data?: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView | unknown): void; - send(data: ArrayBuffer | ArrayBufferView): void; + send(data: ArrayBuffer | ArrayBufferView): void; close(): void; } @@ -182,7 +182,7 @@ class BrowserWebSocket extends Disposable implements IWebSocket { })); } - send(data: ArrayBuffer | ArrayBufferView): void { + send(data: ArrayBuffer | ArrayBufferView): void { if (this._isClosed) { // Refuse to write data to closed WebSocket... return; @@ -254,7 +254,7 @@ class BrowserSocket implements ISocket { } public write(buffer: VSBuffer): void { - this.socket.send(buffer.buffer); + this.socket.send(buffer.buffer as Uint8Array); } public end(): void { diff --git a/src/vs/platform/remote/common/remoteAgentEnvironment.ts b/src/vs/platform/remote/common/remoteAgentEnvironment.ts index 51cb401dcfb85..e63de4e540fea 100644 --- a/src/vs/platform/remote/common/remoteAgentEnvironment.ts +++ b/src/vs/platform/remote/common/remoteAgentEnvironment.ts @@ -12,6 +12,7 @@ export interface IRemoteAgentEnvironment { pid: number; connectionToken: string; appRoot: URI; + execPath: string; tmpDir: URI; settingsPath: URI; mcpResource: URI; diff --git a/src/vs/platform/request/common/request.ts b/src/vs/platform/request/common/request.ts index df18c523dd720..8db0214ed89d8 100644 --- a/src/vs/platform/request/common/request.ts +++ b/src/vs/platform/request/common/request.ts @@ -6,6 +6,7 @@ import { streamToBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { getErrorMessage } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IHeaders, IRequestContext, IRequestOptions } from '../../../base/parts/request/common/request.js'; import { localize } from '../../../nls.js'; @@ -16,6 +17,19 @@ import { Registry } from '../../registry/common/platform.js'; export const IRequestService = createDecorator('requestService'); +/** + * Use as the {@link IRequestOptions.callSite} value to prevent + * request telemetry from being emitted. This is needed for + * callers such as the telemetry sender to avoid cyclical calls. + */ +export const NO_FETCH_TELEMETRY = 'NO_FETCH_TELEMETRY'; + +export interface IRequestCompleteEvent { + readonly callSite: string; + readonly latency: number; + readonly statusCode: number | undefined; +} + export interface AuthInfo { isProxy: boolean; scheme: string; @@ -33,6 +47,11 @@ export interface Credentials { export interface IRequestService { readonly _serviceBrand: undefined; + /** + * Fires when a request completes (successfully or with an error response). + */ + readonly onDidCompleteRequest: Event; + request(options: IRequestOptions, token: CancellationToken): Promise; resolveProxy(url: string): Promise; @@ -70,6 +89,9 @@ export abstract class AbstractRequestService extends Disposable implements IRequ private counter = 0; + private readonly _onDidCompleteRequest = this._register(new Emitter()); + readonly onDidCompleteRequest = this._onDidCompleteRequest.event; + constructor(protected readonly logService: ILogService) { super(); } @@ -77,9 +99,15 @@ export abstract class AbstractRequestService extends Disposable implements IRequ protected async logAndRequest(options: IRequestOptions, request: () => Promise): Promise { const prefix = `#${++this.counter}: ${options.url}`; this.logService.trace(`${prefix} - begin`, options.type, new LoggableHeaders(options.headers ?? {})); + const startTime = Date.now(); try { const result = await request(); this.logService.trace(`${prefix} - end`, options.type, result.res.statusCode, result.res.headers); + this._onDidCompleteRequest.fire({ + callSite: options.callSite, + latency: Date.now() - startTime, + statusCode: result.res.statusCode, + }); return result; } catch (error) { this.logService.error(`${prefix} - error`, options.type, getErrorMessage(error)); @@ -284,6 +312,12 @@ function registerProxyConfigurations(useHostProxy = true, useHostProxyDefault = markdownDescription: localize('fetchAdditionalSupport', "Controls whether Node.js' fetch implementation should be extended with additional support. Currently proxy support ({1}) and system certificates ({2}) are added when the corresponding settings are enabled. When during [remote development](https://aka.ms/vscode-remote) the {0} setting is disabled this setting can be configured in the local and the remote settings separately.", '`#http.useLocalProxyConfiguration#`', '`#http.proxySupport#`', '`#http.systemCertificates#`'), restricted: true }, + 'http.webSocketAdditionalSupport': { + type: 'boolean', + default: true, + markdownDescription: localize('webSocketAdditionalSupport', "Controls whether the built-in WebSocket implementation should be extended with additional support. Currently proxy support ({1}) and system certificates ({2}) are added when the corresponding settings are enabled. When during [remote development](https://aka.ms/vscode-remote) the {0} setting is disabled this setting can be configured in the local and the remote settings separately.", '`#http.useLocalProxyConfiguration#`', '`#http.proxySupport#`', '`#http.systemCertificates#`'), + restricted: true + }, 'http.experimental.networkInterfaceCheckInterval': { type: 'number', default: 300, diff --git a/src/vs/platform/request/common/requestIpc.ts b/src/vs/platform/request/common/requestIpc.ts index 0b3aff1a886d2..341556406fca5 100644 --- a/src/vs/platform/request/common/requestIpc.ts +++ b/src/vs/platform/request/common/requestIpc.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IHeaders, IRequestContext, IRequestOptions } from '../../../base/parts/request/common/request.js'; -import { AuthInfo, Credentials, IRequestService } from './request.js'; +import { AuthInfo, Credentials, IRequestCompleteEvent, IRequestService } from './request.js'; type RequestResponse = [ { @@ -46,6 +46,8 @@ export class RequestChannelClient implements IRequestService { declare readonly _serviceBrand: undefined; + readonly onDidCompleteRequest = Event.None as Event; + constructor(private readonly channel: IChannel) { } async request(options: IRequestOptions, token: CancellationToken): Promise { diff --git a/src/vs/platform/request/test/common/requestService.test.ts b/src/vs/platform/request/test/common/requestService.test.ts new file mode 100644 index 0000000000000..3760902fb1f9c --- /dev/null +++ b/src/vs/platform/request/test/common/requestService.test.ts @@ -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 assert from 'assert'; +import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IRequestContext, IRequestOptions } from '../../../../base/parts/request/common/request.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AbstractRequestService, AuthInfo, Credentials, IRequestCompleteEvent, NO_FETCH_TELEMETRY } from '../../common/request.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +class TestRequestService extends AbstractRequestService { + + constructor(private readonly handler: (options: IRequestOptions) => Promise) { + super(new NullLogService()); + } + + async request(options: IRequestOptions, token: CancellationToken): Promise { + return this.logAndRequest(options, () => this.handler(options)); + } + + async resolveProxy(_url: string): Promise { return undefined; } + async lookupAuthorization(_authInfo: AuthInfo): Promise { return undefined; } + async lookupKerberosAuthorization(_url: string): Promise { return undefined; } + async loadCertificates(): Promise { return []; } +} + +function makeResponse(statusCode: number): IRequestContext { + return { + res: { headers: {}, statusCode }, + stream: bufferToStream(VSBuffer.fromString('')) + }; +} + +suite('AbstractRequestService', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('onDidCompleteRequest fires with correct data', async () => { + const service = store.add(new TestRequestService(() => Promise.resolve(makeResponse(200)))); + + const events: IRequestCompleteEvent[] = []; + store.add(service.onDidCompleteRequest(e => events.push(e))); + + await service.request({ url: 'http://test', callSite: 'test.callSite' }, CancellationToken.None); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].callSite, 'test.callSite'); + assert.strictEqual(events[0].statusCode, 200); + assert.ok(events[0].latency >= 0); + }); + + test('onDidCompleteRequest reports status code from response', async () => { + const service = store.add(new TestRequestService(() => Promise.resolve(makeResponse(404)))); + + const events: IRequestCompleteEvent[] = []; + store.add(service.onDidCompleteRequest(e => events.push(e))); + + await service.request({ url: 'http://test', callSite: 'test.notFound' }, CancellationToken.None); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].statusCode, 404); + }); + + test('onDidCompleteRequest fires for NO_FETCH_TELEMETRY', async () => { + const service = store.add(new TestRequestService(() => Promise.resolve(makeResponse(200)))); + + const events: IRequestCompleteEvent[] = []; + store.add(service.onDidCompleteRequest(e => events.push(e))); + + await service.request({ url: 'http://test', callSite: NO_FETCH_TELEMETRY }, CancellationToken.None); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].callSite, NO_FETCH_TELEMETRY); + }); + + test('onDidCompleteRequest does not fire when request throws', async () => { + const service = store.add(new TestRequestService(() => Promise.reject(new Error('network error')))); + + const events: IRequestCompleteEvent[] = []; + store.add(service.onDidCompleteRequest(e => events.push(e))); + + await assert.rejects(() => service.request({ url: 'http://test', callSite: 'test.error' }, CancellationToken.None)); + + assert.strictEqual(events.length, 0); + }); + + test('onDidCompleteRequest fires for each request', async () => { + const service = store.add(new TestRequestService(() => Promise.resolve(makeResponse(200)))); + + const events: IRequestCompleteEvent[] = []; + store.add(service.onDidCompleteRequest(e => events.push(e))); + + await service.request({ url: 'http://test/1', callSite: 'first' }, CancellationToken.None); + await service.request({ url: 'http://test/2', callSite: 'second' }, CancellationToken.None); + + assert.deepStrictEqual(events.map(e => e.callSite), ['first', 'second']); + }); +}); diff --git a/src/vs/platform/request/test/node/requestService.test.ts b/src/vs/platform/request/test/node/requestService.test.ts index 8e8c885014921..50f7d72068ab9 100644 --- a/src/vs/platform/request/test/node/requestService.test.ts +++ b/src/vs/platform/request/test/node/requestService.test.ts @@ -36,7 +36,7 @@ suite('Request Service', () => { setTimeout(() => cts.cancel(), 50); try { - await nodeRequest({ url: 'http://localhost:9999/nonexistent' }, cts.token); + await nodeRequest({ url: 'http://localhost:9999/nonexistent', callSite: 'requestService.test.cancellation' }, cts.token); assert.fail('Request should have been cancelled'); } catch (err) { const elapsed = Date.now() - startTime; @@ -74,7 +74,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'GET', - getRawRequest: () => mockRawRequest as IRawRequestFunction + getRawRequest: () => mockRawRequest as IRawRequestFunction, + callSite: 'requestService.test.retryGET' }, CancellationToken.None); } catch (err) { // Expected to eventually succeed or fail after retries @@ -106,7 +107,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'POST', - getRawRequest: () => mockRawRequest + getRawRequest: () => mockRawRequest, + callSite: 'requestService.test.noRetryPOST' }, CancellationToken.None); assert.fail('Should have thrown an error'); } catch (err) { @@ -144,7 +146,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'HEAD', - getRawRequest: () => mockRawRequest as IRawRequestFunction + getRawRequest: () => mockRawRequest as IRawRequestFunction, + callSite: 'requestService.test.retryHEAD' }, CancellationToken.None); } catch (err) { // Expected to eventually succeed or fail after retries @@ -181,7 +184,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'OPTIONS', - getRawRequest: () => mockRawRequest as IRawRequestFunction + getRawRequest: () => mockRawRequest as IRawRequestFunction, + callSite: 'requestService.test.retryOPTIONS' }, CancellationToken.None); } catch (err) { // Expected to eventually succeed or fail after retries @@ -213,7 +217,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'DELETE', - getRawRequest: () => mockRawRequest + getRawRequest: () => mockRawRequest, + callSite: 'requestService.test.noRetryDELETE' }, CancellationToken.None); assert.fail('Should have thrown an error'); } catch (err) { @@ -246,7 +251,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'PUT', - getRawRequest: () => mockRawRequest + getRawRequest: () => mockRawRequest, + callSite: 'requestService.test.noRetryPUT' }, CancellationToken.None); assert.fail('Should have thrown an error'); } catch (err) { @@ -279,7 +285,8 @@ suite('Request Service', () => { await nodeRequest({ url: 'http://example.com', type: 'PATCH', - getRawRequest: () => mockRawRequest + getRawRequest: () => mockRawRequest, + callSite: 'requestService.test.noRetryPATCH' }, CancellationToken.None); assert.fail('Should have thrown an error'); } catch (err) { diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 7c5c89eae0886..d1acea8ff5ddc 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -309,13 +309,14 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { } } - const nodeModulesRegex = /^[\\\/]?(node_modules|node_modules\.asar)[\\\/]/; + // Match node_modules or node_modules.asar at any position in the path, capturing the node_modules/... suffix + const nodeModulesRegex = /(?:^|[\\\/])((node_modules|node_modules\.asar)[\\\/].*)$/; // Match VS Code extension paths: // 1. User extensions: .vscode/extensions/, .vscode-insiders/extensions/, .vscode-server/extensions/, .vscode-server-insiders/extensions/, etc. // 2. Built-in extensions: resources/app/extensions/ // Capture everything from the vscode folder or resources/app/extensions onwards const vscodeExtensionsPathRegex = /^(.*?)((?:\.vscode(?:-[a-z]+)*|resources[\\\/]app)[\\\/]extensions[\\\/].*)$/i; - const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w-\._]+(\\\\|\\|\/))+[\w-\._]*/g; + const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w\-\._@]+(\\\\|\\|\/))+[\w\-\._@]*/g; let lastIndex = 0; updatedStack = ''; @@ -329,14 +330,20 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { const overlappingRange = cleanUpIndexes.some(([start, end]) => result.index < end && start < fileRegex.lastIndex); // anoynimize user file paths that do not need to be retained or cleaned up. - if (!nodeModulesRegex.test(result[0]) && !overlappingRange) { + if (!overlappingRange) { // Check if this is a VS Code extension path - if so, preserve the .vscode*/extensions/... portion const vscodeExtMatch = vscodeExtensionsPathRegex.exec(result[0]); if (vscodeExtMatch) { // Keep ".vscode[-variant]/extensions/extension-name/..." but redact the parent folder updatedStack += stack.substring(lastIndex, result.index) + '/' + vscodeExtMatch[2]; } else { - updatedStack += stack.substring(lastIndex, result.index) + ''; + // Check if node_modules appears in the path — preserve node_modules/... suffix + const nodeModulesMatch = nodeModulesRegex.exec(result[0]); + if (nodeModulesMatch) { + updatedStack += stack.substring(lastIndex, result.index) + '/' + nodeModulesMatch[1]; + } else { + updatedStack += stack.substring(lastIndex, result.index) + ''; + } } lastIndex = fileRegex.lastIndex; } diff --git a/src/vs/platform/telemetry/node/1dsAppender.ts b/src/vs/platform/telemetry/node/1dsAppender.ts index 0d3f9369eb56c..0fdbbd1a73257 100644 --- a/src/vs/platform/telemetry/node/1dsAppender.ts +++ b/src/vs/platform/telemetry/node/1dsAppender.ts @@ -7,7 +7,7 @@ import type { IPayloadData, IXHROverride } from '@microsoft/1ds-post-js'; import { streamToBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IRequestOptions } from '../../../base/parts/request/common/request.js'; -import { IRequestService } from '../../request/common/request.js'; +import { IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; import { AbstractOneDataSystemAppender, IAppInsightsCore } from '../common/1dsAppender.js'; type OnCompleteFunc = (status: number, headers: { [headerName: string]: string }, response?: string) => void; @@ -81,7 +81,8 @@ async function sendPostAsync(requestService: IRequestService | undefined, payloa 'Content-Length': Buffer.byteLength(payload.data).toString() }, url: payload.urlString, - data: telemetryRequestData + data: telemetryRequestData, + callSite: NO_FETCH_TELEMETRY }; try { diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index d6a1b2370d511..cf93a8cc0cda3 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -57,7 +57,13 @@ class ErrorTestingSettings { public randomUserFile: string = 'a/path/that/doe_snt/con-tain/code/names.js'; public anonymizedRandomUserFile: string = ''; public nodeModulePathToRetain: string = 'node_modules/path/that/shouldbe/retained/names.js:14:15854'; + public anonymizedNodeModulePath: string = '/node_modules/path/that/shouldbe/retained/names.js:14:15854'; public nodeModuleAsarPathToRetain: string = 'node_modules.asar/path/that/shouldbe/retained/names.js:14:12354'; + public anonymizedNodeModuleAsarPath: string = '/node_modules.asar/path/that/shouldbe/retained/names.js:14:12354'; + public fullNodeModulePath: string = '/Users/username/projects/vscode/node_modules/@xterm/xterm/lib/xterm.js:1:243732'; + public anonymizedFullNodeModulePath: string = '/node_modules/@xterm/xterm/lib/xterm.js:1:243732'; + public fullNodeModuleAsarPath: string = '/Users/username/projects/vscode/node_modules.asar/@xterm/xterm/lib/xterm.js:1:376066'; + public anonymizedFullNodeModuleAsarPath: string = '/node_modules.asar/@xterm/xterm/lib/xterm.js:1:376066'; public extensionPathToRetain: string = '.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; public fullExtensionPath: string = '/Users/username/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; public anonymizedExtensionPath: string = '/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; @@ -90,6 +96,8 @@ class ErrorTestingSettings { ` at t._handleMessage (${this.nodeModuleAsarPathToRetain})`, ` at t._onmessage (/${this.nodeModulePathToRetain})`, ` at t.onmessage (${this.nodeModulePathToRetain})`, + ` at get dimensions (${this.fullNodeModulePath})`, + ` at _._refreshCanvasDimensions (${this.fullNodeModuleAsarPath})`, ` at uv.provideCodeActions (${this.fullExtensionPath})`, ` at remote.handleConnection (${this.fullServerInsidersExtensionPath})`, ` at git.getRepositoryState (${this.fullBuiltinExtensionPath})`, @@ -468,10 +476,8 @@ suite('TelemetryService', () => { assert.strictEqual(errorStub.callCount, 1); // Test that important information remains but personal info does not - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModulePathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModulePathToRetain), -1); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedNodeModuleAsarPath), -1, 'bare node_modules.asar path'); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedNodeModulePath), -1, 'bare node_modules path'); assert.notStrictEqual(testAppender.events[0].data.msg.indexOf(settings.importantInfo), -1); assert.strictEqual(testAppender.events[0].data.msg.indexOf(settings.personalInfo), -1); assert.strictEqual(testAppender.events[0].data.msg.indexOf(settings.filePrefix), -1); @@ -504,10 +510,12 @@ suite('TelemetryService', () => { Errors.onUnexpectedError(dangerousPathWithImportantInfoError); this.clock.tick(ErrorTelemetry.ERROR_FLUSH_TIMEOUT); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModulePathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModulePathToRetain), -1); + // All node_modules paths (bare and full) should preserve the node_modules/... suffix after redaction + const cs = testAppender.events[0].data.callstack; + assert.notStrictEqual(cs.indexOf(settings.anonymizedNodeModuleAsarPath), -1, 'bare node_modules.asar path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedNodeModulePath), -1, 'bare node_modules path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedFullNodeModulePath), -1, 'full node_modules path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedFullNodeModuleAsarPath), -1, 'full node_modules.asar path'); errorTelemetry.dispose(); service.dispose(); @@ -1033,5 +1041,44 @@ suite('TelemetryService', () => { sinon.restore(); })); + test('Unexpected Error Telemetry strips web origin but preserves path in web stack traces when piiPaths includes origin', sinonTestFn(function (this: any) { + const origErrorHandler = Errors.errorHandler.getUnexpectedErrorHandler(); + Errors.setUnexpectedErrorHandler(() => { }); + + try { + const testAppender = new TestTelemetryAppender(); + const webOrigin = 'https://codespace-host.github.dev'; + const service = new TestErrorTelemetryService({ appenders: [testAppender], piiPaths: [webOrigin] }); + const errorTelemetry = new ErrorTelemetry(service); + + const bundlePath = '/static/build/bundle.js'; + const stack = [ + `Error: Something failed`, + ` at x3t._delegate (${webOrigin}${bundlePath}:1:200953)`, + ` at y4u.run (${webOrigin}${bundlePath}:1:304822)`, + ` at DedicatedWorkerGlobalScope.self.onmessage`, + ]; + + const webError: any = new Error('Something failed'); + webError.stack = stack.join('\n'); + + Errors.onUnexpectedError(webError); + this.clock.tick(ErrorTelemetry.ERROR_FLUSH_TIMEOUT); + + assert.strictEqual(testAppender.getEventsCount(), 1); + const cs = testAppender.events[0].data.callstack; + // Verify the web origin is stripped (not leaked as PII) + assert.strictEqual(cs.indexOf(webOrigin), -1, 'Web origin should be stripped'); + assert.strictEqual(cs.indexOf('https://'), -1, 'HTTPS scheme should be stripped'); + // Verify the bundle path is preserved for debugging + assert.notStrictEqual(cs.indexOf(bundlePath), -1, 'Bundle path should be preserved'); + + errorTelemetry.dispose(); + service.dispose(); + } finally { + Errors.setUnexpectedErrorHandler(origErrorHandler); + } + })); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts b/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts index 2a2fe06748544..a4f68648930aa 100644 --- a/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts +++ b/src/vs/platform/terminal/common/capabilities/commandDetection/terminalCommand.ts @@ -131,13 +131,25 @@ export class TerminalCommand implements ITerminalCommand { return undefined; } let output = ''; + let currentLine = ''; let line: IBufferLine | undefined; + const buffer = this._xterm.buffer.active; for (let i = startLine; i < endLine; i++) { - line = this._xterm.buffer.active.getLine(i); + line = buffer.getLine(i); if (!line) { continue; } - output += line.translateToString(!line.isWrapped) + (line.isWrapped ? '' : '\n'); + // NOTE: xterm stores wrapping state on the *next* line, not the current one. + // Use next line's `isWrapped` to determine whether this line should be joined. + const isWrapped = i + 1 < endLine ? !!buffer.getLine(i + 1)?.isWrapped : false; + currentLine += line.translateToString(!isWrapped); + if (!isWrapped) { + output += currentLine + '\n'; + currentLine = ''; + } + } + if (currentLine.length > 0) { + output += currentLine; } return output === '' ? undefined : output; } diff --git a/src/vs/platform/terminal/node/childProcessMonitor.ts b/src/vs/platform/terminal/node/childProcessMonitor.ts index 40ac2d2305071..2b9bdfb2e5b2d 100644 --- a/src/vs/platform/terminal/node/childProcessMonitor.ts +++ b/src/vs/platform/terminal/node/childProcessMonitor.ts @@ -49,12 +49,20 @@ export class ChildProcessMonitor extends Disposable { readonly onDidChangeHasChildProcesses = this._onDidChangeHasChildProcesses.event; constructor( - private readonly _pid: number, + private _pid: number, @ILogService private readonly _logService: ILogService ) { super(); } + /** + * Updates the pid to monitor. This is needed when the pid is not available + * immediately after spawn (e.g. node-pty deferred conpty connection). + */ + setPid(pid: number): void { + this._pid = pid; + } + /** * Input was triggered on the process. */ diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 5aa0a0dd13a6e..178bed35e83c2 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -118,6 +118,7 @@ export async function getShellIntegrationInjection( if (!newArgs) { return { type: 'failure', reason: ShellIntegrationInjectionFailureReason.UnsupportedArgs }; } + newArgs = [...newArgs]; newArgs[newArgs.length - 1] = format(newArgs[newArgs.length - 1], appRoot, ''); envMixin['VSCODE_STABLE'] = productService.quality === 'stable' ? '1' : '0'; return { type, newArgs, envMixin }; diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 60563ff6487a8..efb8c243bd37b 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -31,7 +31,7 @@ const enum ShutdownConstants { * on Windows under conpty, killing a process while data is being output will cause the [conhost * flush to hang the pty host][2] because [conhost should be hosted on another thread][3]. * - * [1]: https://github.com/Tyriar/node-pty/issues/72 + * [1]: https://github.com/microsoft/node-pty/issues/72 * [2]: https://github.com/microsoft/vscode/issues/71966 * [3]: https://github.com/microsoft/node-pty/pull/415 */ @@ -337,7 +337,20 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._exitCode = e.exitCode; this._queueProcessExit(); })); - this._sendProcessId(ptyProcess.pid); + // node-pty >= 1.2.0-beta.11 defers conptyNative.connect() on Windows, so + // ptyProcess.pid may be 0 immediately after spawn. In that case we wait + // for the first data event which only fires after the connection completes + // and the real pid is available. See microsoft/node-pty#885. + if (ptyProcess.pid > 0) { + this._sendProcessId(ptyProcess.pid); + } else { + const dataListener = ptyProcess.onData(() => { + dataListener.dispose(); + this._childProcessMonitor?.setPid(ptyProcess.pid); + this._sendProcessId(ptyProcess.pid); + }); + this._register(dataListener); + } this._setupTitlePolling(ptyProcess); } @@ -355,7 +368,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess } // Allow any trailing data events to be sent before the exit event is sent. - // See https://github.com/Tyriar/node-pty/issues/72 + // See https://github.com/microsoft/node-pty/issues/72 private _queueProcessExit() { if (this._logService.getLevel() === LogLevel.Trace) { this._logService.trace('TerminalProcess#_queueProcessExit', new Error().stack?.replace(/^Error/, '')); diff --git a/src/vs/platform/theme/browser/defaultStyles.ts b/src/vs/platform/theme/browser/defaultStyles.ts index 49e08fbf8dab6..bd8147af0d4ac 100644 --- a/src/vs/platform/theme/browser/defaultStyles.ts +++ b/src/vs/platform/theme/browser/defaultStyles.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IButtonStyles } from '../../../base/browser/ui/button/button.js'; import { IKeybindingLabelStyles } from '../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; -import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionForeground, menuSelectionBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder } from '../common/colorRegistry.js'; +import { ColorIdentifier, keybindingLabelBackground, keybindingLabelBorder, keybindingLabelBottomBorder, keybindingLabelForeground, asCssVariable, widgetShadow, buttonForeground, buttonSeparator, buttonBackground, buttonHoverBackground, buttonSecondaryForeground, buttonSecondaryBackground, buttonSecondaryHoverBackground, buttonBorder, progressBarBackground, inputActiveOptionBorder, inputActiveOptionForeground, inputActiveOptionBackground, editorWidgetBackground, editorWidgetForeground, contrastBorder, checkboxBorder, checkboxBackground, checkboxForeground, problemsErrorIconForeground, problemsWarningIconForeground, problemsInfoIconForeground, inputBackground, inputForeground, inputBorder, textLinkForeground, inputValidationInfoBorder, inputValidationInfoBackground, inputValidationInfoForeground, inputValidationWarningBorder, inputValidationWarningBackground, inputValidationWarningForeground, inputValidationErrorBorder, inputValidationErrorBackground, inputValidationErrorForeground, listFilterWidgetBackground, listFilterWidgetNoMatchesOutline, listFilterWidgetOutline, listFilterWidgetShadow, badgeBackground, badgeForeground, breadcrumbsBackground, breadcrumbsForeground, breadcrumbsFocusForeground, breadcrumbsActiveSelectionForeground, activeContrastBorder, listActiveSelectionBackground, listActiveSelectionForeground, listActiveSelectionIconForeground, listDropOverBackground, listFocusAndSelectionOutline, listFocusBackground, listFocusForeground, listFocusOutline, listHoverBackground, listHoverForeground, listInactiveFocusBackground, listInactiveFocusOutline, listInactiveSelectionBackground, listInactiveSelectionForeground, listInactiveSelectionIconForeground, tableColumnsBorder, tableOddRowsBackgroundColor, treeIndentGuidesStroke, asCssVariableWithDefault, editorWidgetBorder, focusBorder, pickerGroupForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, selectBackground, selectBorder, selectForeground, selectListBackground, treeInactiveIndentGuidesStroke, menuBorder, menuForeground, menuBackground, menuSelectionBorder, menuSeparatorBackground, scrollbarShadow, scrollbarSliderActiveBackground, scrollbarSliderBackground, scrollbarSliderHoverBackground, listDropBetweenBackground, radioActiveBackground, radioActiveForeground, radioInactiveBackground, radioInactiveForeground, radioInactiveBorder, radioInactiveHoverBackground, radioActiveBorder, checkboxDisabledBackground, checkboxDisabledForeground, widgetBorder } from '../common/colorRegistry.js'; import { IProgressBarStyles } from '../../../base/browser/ui/progressbar/progressbar.js'; import { ICheckboxStyles, IToggleStyles } from '../../../base/browser/ui/toggle/toggle.js'; import { IDialogStyles } from '../../../base/browser/ui/dialog/dialog.js'; @@ -244,8 +244,8 @@ export const defaultMenuStyles: IMenuStyles = { borderColor: asCssVariable(menuBorder), foregroundColor: asCssVariable(menuForeground), backgroundColor: asCssVariable(menuBackground), - selectionForegroundColor: asCssVariable(menuSelectionForeground), - selectionBackgroundColor: asCssVariable(menuSelectionBackground), + selectionForegroundColor: asCssVariable(listHoverForeground), + selectionBackgroundColor: asCssVariable(listHoverBackground), selectionBorderColor: asCssVariable(menuSelectionBorder), separatorColor: asCssVariable(menuSeparatorBackground), scrollbarShadow: asCssVariable(scrollbarShadow), diff --git a/src/vs/platform/theme/common/colorUtils.ts b/src/vs/platform/theme/common/colorUtils.ts index f55c8aad640a9..6f725c9c9244a 100644 --- a/src/vs/platform/theme/common/colorUtils.ts +++ b/src/vs/platform/theme/common/colorUtils.ts @@ -126,6 +126,11 @@ export interface IColorRegistry { */ getColorReferenceSchema(): IJSONSchema; + /** + * Update the default color of a color identifier. + */ + updateDefaultColor(id: string, defaults: ColorDefaults | ColorValue | null): void; + /** * Notify when the color theme or settings change. */ @@ -186,6 +191,13 @@ class ColorRegistry extends Disposable implements IColorRegistry { } + public updateDefaultColor(id: string, defaults: ColorDefaults | ColorValue | null): void { + const existing = this.colorsById[id]; + if (existing) { + this.colorsById[id] = { ...existing, defaults }; + } + } + public deregisterColor(id: string): void { delete this.colorsById[id]; delete this.colorSchema.properties[id]; diff --git a/src/vs/platform/theme/electron-main/themeMainService.ts b/src/vs/platform/theme/electron-main/themeMainService.ts index 531fc6171e67d..94c46d84185e5 100644 --- a/src/vs/platform/theme/electron-main/themeMainService.ts +++ b/src/vs/platform/theme/electron-main/themeMainService.ts @@ -23,4 +23,12 @@ export interface IThemeMainService { getWindowSplash(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined): IPartsSplash | undefined; getColorScheme(): IColorScheme; + + /** + * Whether OS color-scheme auto-detection is active. + * Returns `true` when the `window.autoDetectColorScheme` setting is enabled, + * or for fresh installs where no theme has been stored yet and the user + * has not explicitly configured the setting (e.g. via settings sync). + */ + isAutoDetectColorScheme(): boolean; } diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 25e5fe37f7799..56c103875a69c 100644 --- a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts +++ b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts @@ -113,7 +113,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } private updateSystemColorTheme(): void { - if (isLinux || Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + if (isLinux || this.isAutoDetectColorScheme()) { electron.nativeTheme.themeSource = 'system'; // only with `system` we can detect the system color scheme } else { switch (Setting.SYSTEM_COLOR_THEME.getValue(this.configurationService)) { @@ -174,13 +174,26 @@ export class ThemeMainService extends Disposable implements IThemeMainService { return colorScheme.dark ? ThemeTypeSelector.HC_BLACK : ThemeTypeSelector.HC_LIGHT; } - if (Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + if (this.isAutoDetectColorScheme()) { return colorScheme.dark ? ThemeTypeSelector.VS_DARK : ThemeTypeSelector.VS; } return undefined; } + isAutoDetectColorScheme(): boolean { + if (Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + return true; + } + // For new installs with no stored theme, auto-detect OS color scheme by default + // unless the user has explicitly configured the setting (e.g. via settings sync) + if (!this.stateService.getItem(THEME_STORAGE_KEY)) { + const { userValue } = this.configurationService.inspect(Setting.DETECT_COLOR_SCHEME.key); + return userValue === undefined; + } + return false; + } + getBackgroundColor(): string { const preferred = this.getPreferredBaseTheme(); const stored = this.getStoredBaseTheme(); diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 93dd62df97ec6..53e3a783872ff 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -7,6 +7,7 @@ import { isWeb, isWindows } from '../../../base/common/platform.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { localize } from '../../../nls.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -89,6 +90,21 @@ configurationRegistry.registerConfiguration({ localize('actionable', "The status bar entry is shown when an action is required (e.g., download, install, or restart)."), localize('detailed', "The status bar entry is shown for all update states including progress.") ] + }, + 'update.titleBar': { + type: 'string', + enum: ['none', 'actionable', 'detailed', 'always'], + default: product.quality !== 'stable' ? 'actionable' : 'none', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + experiment: { mode: 'startup' }, + description: localize('titleBar', "Controls the experimental update title bar entry."), + enumDescriptions: [ + localize('titleBarNone', "The title bar entry is never shown."), + localize('titleBarActionable', "The title bar entry is shown when an action is required (e.g., download, install, or restart)."), + localize('titleBarDetailed', "The title bar entry is shown for progress and actionable update states, but not for idle or disabled states."), + localize('titleBarAlways', "The title bar entry is shown for all update states.") + ] } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 7f30494da4a37..bc90a03ad8c78 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -59,17 +59,17 @@ export const enum DisablementReason { NotBuilt, DisabledByEnvironment, ManuallyDisabled, + Policy, MissingConfiguration, InvalidConfiguration, RunningAsAdmin, - EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; -export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; +export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string; notAvailable?: boolean }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; -export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; +export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate; canInstall?: boolean }; export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number }; @@ -81,9 +81,9 @@ export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | Avail export const State = { Uninitialized: upcast({ type: StateType.Uninitialized }), Disabled: (reason: DisablementReason): Disabled => ({ type: StateType.Disabled, reason }), - Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), + Idle: (updateType: UpdateType, error?: string, notAvailable?: boolean): Idle => ({ type: StateType.Idle, updateType, error, notAvailable }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), - AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), + AvailableForDownload: (update: IUpdate, canInstall?: boolean): AvailableForDownload => ({ type: StateType.AvailableForDownload, update, canInstall }), Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }), @@ -111,7 +111,10 @@ export interface IUpdateService { applyUpdate(): Promise; quitAndInstall(): Promise; + /** + * @deprecated This method should not be used any more. It will be removed in a future release. + */ isLatestVersion(): Promise; _applySpecificUpdate(packagePath: string): Promise; - disableProgressiveReleases(): Promise; + setInternalOrg(internalOrg: string | undefined): Promise; } diff --git a/src/vs/platform/update/common/updateIpc.ts b/src/vs/platform/update/common/updateIpc.ts index 9eaf8210757e2..6b165c49d2146 100644 --- a/src/vs/platform/update/common/updateIpc.ts +++ b/src/vs/platform/update/common/updateIpc.ts @@ -29,7 +29,7 @@ export class UpdateChannel implements IServerChannel { case '_getInitialState': return Promise.resolve(this.service.state); case 'isLatestVersion': return this.service.isLatestVersion(); case '_applySpecificUpdate': return this.service._applySpecificUpdate(arg); - case 'disableProgressiveReleases': return this.service.disableProgressiveReleases(); + case 'setInternalOrg': return this.service.setInternalOrg(arg); } throw new Error(`Call not found: ${command}`); @@ -80,8 +80,8 @@ export class UpdateChannelClient implements IUpdateService { return this.channel.call('_applySpecificUpdate', packagePath); } - disableProgressiveReleases(): Promise { - return this.channel.call('disableProgressiveReleases'); + setInternalOrg(internalOrg: string | undefined): Promise { + return this.channel.call('setInternalOrg', internalOrg); } dispose(): void { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 698d277ca288b..c46686f9905f9 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -16,10 +16,16 @@ import { ILifecycleMainService, LifecycleMainPhase } from '../../lifecycle/elect import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { IRequestService } from '../../request/common/request.js'; +import { StorageScope, StorageTarget } from '../../storage/common/storage.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, DisablementReason, IUpdateService, State, StateType, UpdateType } from '../common/update.js'; +const LAST_KNOWN_VERSION_STORAGE_KEY = 'abstractUpdateService/lastKnownVersion'; + export interface IUpdateURLOptions { readonly background?: boolean; + readonly internalOrg?: string; } export function createUpdateURL(baseUpdateUrl: string, platform: string, quality: string, commit: string, options?: IUpdateURLOptions): string { @@ -29,6 +35,8 @@ export function createUpdateURL(baseUpdateUrl: string, platform: string, quality url.searchParams.set('bg', 'true'); } + url.searchParams.set('u', options?.internalOrg ?? 'none'); + return url.toString(); } @@ -77,7 +85,7 @@ export abstract class AbstractUpdateService implements IUpdateService { protected _overwrite: boolean = false; private _hasCheckedForOverwriteOnQuit: boolean = false; private readonly overwriteUpdatesCheckInterval = new IntervalTimer(); - private _disableProgressiveReleases: boolean = false; + private _internalOrg: string | undefined = undefined; private readonly _onStateChange = new Emitter(); readonly onStateChange: Event = this._onStateChange.event; @@ -91,6 +99,12 @@ export abstract class AbstractUpdateService implements IUpdateService { this._state = state; this._onStateChange.fire(state); + // Clear transient one-time properties from Idle state after delivering the event. + // This prevents new windows from seeing stale error/notAvailable messages. + if (state.type === StateType.Idle && (state.error || state.notAvailable)) { + this._state = State.Idle(state.updateType); + } + // Schedule 5-minute checks when in Ready state and overwrite is supported if (this.supportsUpdateOverwrite) { if (state.type === StateType.Ready) { @@ -108,6 +122,8 @@ export abstract class AbstractUpdateService implements IUpdateService { @IRequestService protected requestService: IRequestService, @ILogService protected logService: ILogService, @IProductService protected readonly productService: IProductService, + @ITelemetryService protected readonly telemetryService: ITelemetryService, + @IApplicationStorageMainService protected readonly applicationStorageMainService: IApplicationStorageMainService, @IMeteredConnectionService protected readonly meteredConnectionService: IMeteredConnectionService, protected readonly supportsUpdateOverwrite: boolean, ) { @@ -126,6 +142,8 @@ export abstract class AbstractUpdateService implements IUpdateService { return; // updates are never enabled when running out of sources } + await this.trackVersionChange(); + if (this.environmentMainService.disableUpdates) { this.setState(State.Disabled(DisablementReason.DisabledByEnvironment)); this.logService.info('update#ctor - updates are disabled by the environment'); @@ -139,11 +157,18 @@ export abstract class AbstractUpdateService implements IUpdateService { } const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); + const updateModeInspection = this.configurationService.inspect<'none' | 'manual' | 'start' | 'default'>('update.mode'); + const policyDisablesUpdates = updateModeInspection.policyValue !== undefined && !this.getProductQuality(updateModeInspection.policyValue); const quality = this.getProductQuality(updateMode); if (!quality) { - this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.logService.info('update#ctor - updates are disabled by user preference'); + if (policyDisablesUpdates) { + this.setState(State.Disabled(DisablementReason.Policy)); + this.logService.info('update#ctor - updates are disabled by policy'); + } else { + this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); + this.logService.info('update#ctor - updates are disabled by user preference'); + } return; } @@ -175,6 +200,77 @@ export abstract class AbstractUpdateService implements IUpdateService { } } + private async trackVersionChange(): Promise { + await this.applicationStorageMainService.whenReady; + + interface ILastKnownVersion { + readonly version: string; + readonly commit: string | undefined; + readonly timestamp: number; + } + + let from: ILastKnownVersion | undefined; + const raw = this.applicationStorageMainService.get(LAST_KNOWN_VERSION_STORAGE_KEY, StorageScope.APPLICATION); + if (typeof raw === 'string') { + try { + from = JSON.parse(raw); + } catch (error) { + // ignore + } + } + + const to: ILastKnownVersion = { + version: this.productService.version, + commit: this.productService.commit, + timestamp: Date.now(), + }; + + if (from?.commit === to.commit) { + return; + } + + this.applicationStorageMainService.store(LAST_KNOWN_VERSION_STORAGE_KEY, JSON.stringify(to), StorageScope.APPLICATION, StorageTarget.MACHINE); + + if (!from) { + return; + } + + type VersionChangeEvent = { + fromVersion: string | undefined; + fromCommit: string | undefined; + fromVersionTime: number | undefined; + toVersion: string; + toCommit: string | undefined; + timeToUpdateMs: number | undefined; + updateMode: string | undefined; + titleBarMode: string | undefined; + }; + + type VersionChangeClassification = { + owner: 'dmitriv'; + comment: 'Fired when VS Code detects a version change on startup.'; + fromVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The previous version of VS Code.' }; + fromCommit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The commit hash of the previous version.' }; + fromVersionTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Timestamp when the previous version was first detected.' }; + toVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The current version of VS Code.' }; + toCommit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The commit hash of the current version.' }; + timeToUpdateMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Milliseconds between the previous version install and this version install.' }; + updateMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The update mode configured by the user.' }; + titleBarMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The title bar update indicator mode configured by the user.' }; + }; + + this.telemetryService.publicLog2('update:versionChanged', { + fromVersion: from.version, + fromCommit: from.commit, + fromVersionTime: from.timestamp, + toVersion: to.version, + toCommit: to.commit, + timeToUpdateMs: to.timestamp - from.timestamp, + updateMode: this.configurationService.getValue('update.mode'), + titleBarMode: this.configurationService.getValue('update.titleBar') + }); + } + private getProductQuality(updateMode: string): string | undefined { return updateMode === 'none' ? undefined : this.productService.quality; } @@ -314,7 +410,7 @@ export abstract class AbstractUpdateService implements IUpdateService { return undefined; } - const url = this.buildUpdateFeedUrl(this.quality, commit ?? this.productService.commit!); + const url = this.buildUpdateFeedUrl(this.quality, commit ?? this.productService.commit!, { internalOrg: this.getInternalOrg() }); if (!url) { return undefined; @@ -324,7 +420,7 @@ export abstract class AbstractUpdateService implements IUpdateService { this.logService.trace('update#isLatestVersion() - checking update server', { url, headers }); try { - const context = await this.requestService.request({ url, headers }, token); + const context = await this.requestService.request({ url, headers, callSite: 'updateService.isLatestVersion' }, token); const statusCode = context.res.statusCode; this.logService.trace('update#isLatestVersion() - response', { statusCode }); // The update server replies with 204 (No Content) when no @@ -342,13 +438,17 @@ export abstract class AbstractUpdateService implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { - this.logService.info('update#disableProgressiveReleases'); - this._disableProgressiveReleases = true; + async setInternalOrg(internalOrg: string | undefined): Promise { + if (this._internalOrg === internalOrg) { + return; + } + + this.logService.info('update#setInternalOrg', internalOrg); + this._internalOrg = internalOrg; } - protected shouldDisableProgressiveReleases(): boolean { - return this._disableProgressiveReleases; + protected getInternalOrg(): string | undefined { + return this._internalOrg; } protected getUpdateType(): UpdateType { diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index a0c89233f3d4b..cbf603459c5f1 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -15,8 +15,9 @@ import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../.. import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; import { INodeProcess } from '../../../base/common/platform.js'; @@ -40,14 +41,15 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IConfigurationService configurationService: IConfigurationService, - @ITelemetryService private readonly telemetryService: ITelemetryService, + @ITelemetryService telemetryService: ITelemetryService, @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @IRequestService requestService: IRequestService, @ILogService logService: ILogService, @IProductService productService: IProductService, + @IApplicationStorageMainService applicationStorageMainService: IApplicationStorageMainService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, telemetryService, applicationStorageMainService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -68,13 +70,15 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } protected override async initialize(): Promise { + await super.initialize(); + + // In the embedded app we still want to detect available updates via HTTP, + // but we must not wire up Electron's autoUpdater (which auto-downloads). if ((process as INodeProcess).isEmbeddedApp) { - this.setState(State.Disabled(DisablementReason.EmbeddedApp)); - this.logService.info('update#ctor - updates are disabled for embedded app'); + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); return; } - await super.initialize(); this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); @@ -127,13 +131,21 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.setState(State.CheckingForUpdates(explicit)); - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); if (!url) { return; } + // In the embedded app, always check without triggering Electron's auto-download. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: checking for update without auto-download'); + this.checkForUpdateNoDownload(url, /* canInstall */ false); + return; + } + // When connection is metered and this is not an explicit check, avoid electron call as to not to trigger auto-download. if (!explicit && this.meteredConnectionService.isConnectionMetered) { this.logService.info('update#doCheckForUpdates - checking for update without auto-download because connection is metered'); @@ -147,24 +159,26 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau /** * Manually check the update feed URL without triggering Electron's auto-download. - * Used when connection is metered to show update availability without downloading. + * Used when connection is metered or in the embedded app. + * @param canInstall When false, signals that the update cannot be installed from this app. */ - private async checkForUpdateNoDownload(url: string): Promise { + private async checkForUpdateNoDownload(url: string, canInstall?: boolean): Promise { const headers = getUpdateRequestHeaders(this.productService.version); this.logService.trace('update#checkForUpdateNoDownload - checking update server', { url, headers }); try { - const context = await this.requestService.request({ url, headers }, CancellationToken.None); + const context = await this.requestService.request({ url, headers, callSite: 'updateService.darwin.checkForUpdates' }, CancellationToken.None); const statusCode = context.res.statusCode; this.logService.trace('update#checkForUpdateNoDownload - response', { statusCode }); const update = await asJson(context); if (!update || !update.url || !update.version || !update.productVersion) { this.logService.trace('update#checkForUpdateNoDownload - no update available'); - this.setState(State.Idle(UpdateType.Archive)); + const notAvailable = this.state.type === StateType.CheckingForUpdates && this.state.explicit; + this.setState(State.Idle(UpdateType.Archive, undefined, notAvailable || undefined)); } else { this.logService.trace('update#checkForUpdateNoDownload - update available', { version: update.version, productVersion: update.productVersion }); - this.setState(State.AvailableForDownload(update)); + this.setState(State.AvailableForDownload(update, canInstall)); } } catch (err) { this.logService.error('update#checkForUpdateNoDownload - failed to check for update', err); @@ -200,12 +214,13 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } - this.setState(State.Idle(UpdateType.Archive)); + const notAvailable = this.state.explicit; + this.setState(State.Idle(UpdateType.Archive, undefined, notAvailable || undefined)); } protected override async doDownloadUpdate(state: AvailableForDownload): Promise { // Rebuild feed URL and trigger download via Electron's auto-updater - this.buildUpdateFeedUrl(this.quality!, state.update.version); + this.buildUpdateFeedUrl(this.quality!, state.update.version, { internalOrg: this.getInternalOrg() }); this.setState(State.CheckingForUpdates(true)); electron.autoUpdater.checkForUpdates(); } diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index ee4b291a87ad3..2be53f613228d 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -12,6 +12,8 @@ import { IMeteredConnectionService } from '../../meteredConnection/common/metere import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, IUpdate, State, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions } from './abstractUpdateService.js'; @@ -25,9 +27,11 @@ export class LinuxUpdateService extends AbstractUpdateService { @ILogService logService: ILogService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, @IProductService productService: IProductService, + @ITelemetryService telemetryService: ITelemetryService, + @IApplicationStorageMainService applicationStorageMainService: IApplicationStorageMainService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, telemetryService, applicationStorageMainService, meteredConnectionService, false); } protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string { @@ -39,15 +43,16 @@ export class LinuxUpdateService extends AbstractUpdateService { return; } - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background, internalOrg }); this.setState(State.CheckingForUpdates(explicit)); - this.requestService.request({ url }, CancellationToken.None) + this.requestService.request({ url, callSite: 'updateService.linux.checkForUpdates' }, CancellationToken.None) .then(asJson) .then(update => { if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(UpdateType.Archive)); + this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); } else { this.setState(State.AvailableForDownload(update)); } diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index b09111d023506..6b941c8985480 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -31,6 +31,12 @@ abstract class AbstractUpdateService implements IUpdateService { this.logService.info('update#setState', state.type); this._state = state; this._onStateChange.fire(state); + + // Clear transient one-time properties from Idle state after delivering the event. + // This prevents new windows from seeing stale error/notAvailable messages. + if (state.type === StateType.Idle && (state.error || state.notAvailable)) { + this._state = State.Idle(state.updateType); + } } constructor( @@ -133,7 +139,7 @@ abstract class AbstractUpdateService implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { + async setInternalOrg(_internalOrg: string | undefined): Promise { // noop - not applicable for snap } @@ -176,7 +182,7 @@ export class SnapUpdateService extends AbstractUpdateService { if (result) { this.setState(State.Ready({ version: 'something' }, false, false)); } else { - this.setState(State.Idle(UpdateType.Snap)); + this.setState(State.Idle(UpdateType.Snap, undefined, undefined)); } }, err => { this.logService.error(err); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index c4b6083f99a69..b73f09450c5bd 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -30,9 +30,11 @@ import { IMeteredConnectionService } from '../../meteredConnection/common/metere import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; +import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { INodeProcess } from '../../../base/common/platform.js'; interface IAvailableUpdate { packagePath: string; @@ -68,16 +70,17 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, @IConfigurationService configurationService: IConfigurationService, - @ITelemetryService private readonly telemetryService: ITelemetryService, + @ITelemetryService telemetryService: ITelemetryService, @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @IRequestService requestService: IRequestService, @ILogService logService: ILogService, @IFileService private readonly fileService: IFileService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, @IProductService productService: IProductService, + @IApplicationStorageMainService applicationStorageMainService: IApplicationStorageMainService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, telemetryService, applicationStorageMainService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -98,6 +101,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override async initialize(): Promise { + // In the embedded app, skip win32-specific setup (cache paths, telemetry) + // but still run the base initialization to detect available updates. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); + await super.initialize(); + return; + } + if (this.productService.win32VersionedUpdate) { const cachePath = await this.cachePath; app.setPath('appUpdate', cachePath); @@ -188,8 +199,9 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return; } - const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + const internalOrg = this.getInternalOrg(); + const background = !explicit && !internalOrg; + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background, internalOrg }); // Only set CheckingForUpdates if we're not already in Overwriting state if (this.state.type !== StateType.Overwriting) { @@ -197,7 +209,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } const headers = getUpdateRequestHeaders(this.productService.version); - this.requestService.request({ url, headers }, CancellationToken.None) + this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, CancellationToken.None) .then(asJson) .then(update => { const updateType = getUpdateType(); @@ -209,7 +221,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this._overwrite = false; this.setState(State.Ready(this.state.update, this.state.explicit, false)); } else { - this.setState(State.Idle(updateType)); + this.setState(State.Idle(updateType, undefined, explicit || undefined)); } return Promise.resolve(null); } @@ -219,6 +231,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } + // In the embedded app, signal that an update exists but can't be installed here. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: update available, skipping download'); + this.setState(State.AvailableForDownload(update, /* canInstall */ false)); + return Promise.resolve(null); + } + // When connection is metered and this is not an explicit check, // show update is available but don't start downloading if (!explicit && this.meteredConnectionService.isConnectionMetered) { @@ -239,7 +258,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; - return this.requestService.request({ url: update.url }, CancellationToken.None) + return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, CancellationToken.None) .then(context => { // Get total size from Content-Length header const contentLengthHeader = context.res.headers['content-length']; diff --git a/src/vs/platform/url/common/urlGlob.ts b/src/vs/platform/url/common/urlGlob.ts index 9cfd6f530d209..9202ee672cdd4 100644 --- a/src/vs/platform/url/common/urlGlob.ts +++ b/src/vs/platform/url/common/urlGlob.ts @@ -131,7 +131,10 @@ function doUrlPartMatch( if (!['/', ':'].includes(urlPart[urlOffset])) { options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset + 1, globUrlOffset)); } - options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + // Only skip *. if we're at the start (bare domain) or at a dot boundary + if (urlOffset === 0 || urlPart[urlOffset - 1] === '.') { + options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + } } if (globUrlPart[globUrlOffset] === '*') { diff --git a/src/vs/platform/url/electron-main/electronUrlListener.ts b/src/vs/platform/url/electron-main/electronUrlListener.ts index 49c508e845093..fe9ce0b757d5f 100644 --- a/src/vs/platform/url/electron-main/electronUrlListener.ts +++ b/src/vs/platform/url/electron-main/electronUrlListener.ts @@ -7,7 +7,7 @@ import { app, Event as ElectronEvent } from 'electron'; import { disposableTimeout } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILogService } from '../../log/common/log.js'; @@ -50,8 +50,9 @@ export class ElectronURLListener extends Disposable { // Windows: install as protocol handler // Skip in portable mode: the registered command wouldn't preserve - // portable mode settings, causing issues with OAuth flows - if (isWindows && !environmentMainService.isPortable) { + // portable mode settings, causing issues with OAuth flows. + // Skip for embedded apps: protocol handler is registered at install time. + if (isWindows && !environmentMainService.isPortable && !(process as INodeProcess).isEmbeddedApp) { const windowsParameters = environmentMainService.isBuilt ? [] : [`"${environmentMainService.appRoot}"`]; windowsParameters.push('--open-url', '--'); app.setAsDefaultProtocolClient(productService.urlProtocol, process.execPath, windowsParameters); diff --git a/src/vs/platform/url/test/common/urlGlob.test.ts b/src/vs/platform/url/test/common/urlGlob.test.ts index 83534f62ad609..90fee896069d4 100644 --- a/src/vs/platform/url/test/common/urlGlob.test.ts +++ b/src/vs/platform/url/test/common/urlGlob.test.ts @@ -56,8 +56,29 @@ suite('urlGlob', () => { assert.strictEqual(testUrlMatchesGlob('https://sub.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://sub.domain.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://example.com', 'https://*.example.com'), true); - // *. matches any number of characters before the domain, including other domains - assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), true); + }); + + test('subdomain wildcard must match on dot boundary', () => { + // Should NOT match: no dot boundary before the domain + assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evilmicrosoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-example.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://myexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://notexample.com/path', 'https://*.example.com/path'), false); + + // Should match: proper subdomain with dot boundary + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://a.b.c.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://sub.example.com/path', 'https://*.example.com/path'), true); + }); + + test('subdomain wildcard without scheme must match on dot boundary', () => { + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('http://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', '*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('http://sub.microsoft.com', '*.microsoft.com'), true); }); test('port matching', () => { diff --git a/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts b/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts index e5642bc2b8c40..cda7b80d8bc4c 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncStoreService.ts @@ -258,7 +258,7 @@ export class UserDataSyncStoreClient extends Disposable { headers = { ...headers }; headers['Content-Type'] = 'application/json'; - const context = await this.request(url, { type: 'GET', headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.getAllCollections' }, [], CancellationToken.None); return (await asJson<{ id: string }[]>(context))?.map(({ id }) => id) || []; } @@ -272,7 +272,7 @@ export class UserDataSyncStoreClient extends Disposable { headers = { ...headers }; headers['Content-Type'] = Mimes.text; - const context = await this.request(url, { type: 'POST', headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'POST', headers, callSite: 'userDataSync.createCollection' }, [], CancellationToken.None); const collectionId = await asTextOrError(context); if (!collectionId) { throw new UserDataSyncStoreError('Server did not return the collection id', url, UserDataSyncErrorCode.NoCollection, context.res.statusCode, context.res.headers[HEADER_OPERATION_ID]); @@ -288,7 +288,7 @@ export class UserDataSyncStoreClient extends Disposable { const url = collection ? joinPath(this.userDataSyncStoreUrl, 'collection', collection).toString() : joinPath(this.userDataSyncStoreUrl, 'collection').toString(); headers = { ...headers }; - await this.request(url, { type: 'DELETE', headers }, [], CancellationToken.None); + await this.request(url, { type: 'DELETE', headers, callSite: 'userDataSync.deleteCollection' }, [], CancellationToken.None); } // #endregion @@ -303,7 +303,7 @@ export class UserDataSyncStoreClient extends Disposable { const uri = this.getResourceUrl(this.userDataSyncStoreUrl, collection, resource); const headers: IHeaders = {}; - const context = await this.request(uri.toString(), { type: 'GET', headers }, [], CancellationToken.None); + const context = await this.request(uri.toString(), { type: 'GET', headers, callSite: 'userDataSync.getAllResourceRefs' }, [], CancellationToken.None); const result = await asJson<{ url: string; created: number }[]>(context) || []; return result.map(({ url, created }) => ({ ref: relativePath(uri, uri.with({ path: url }))!, created: created * 1000 /* Server returns in seconds */ })); @@ -318,7 +318,7 @@ export class UserDataSyncStoreClient extends Disposable { headers = { ...headers }; headers['Cache-Control'] = 'no-cache'; - const context = await this.request(url, { type: 'GET', headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.resolveResourceContent' }, [], CancellationToken.None); const content = await asTextOrError(context); return content; } @@ -331,7 +331,7 @@ export class UserDataSyncStoreClient extends Disposable { const url = ref !== null ? joinPath(this.getResourceUrl(this.userDataSyncStoreUrl, collection, resource), ref).toString() : this.getResourceUrl(this.userDataSyncStoreUrl, collection, resource).toString(); const headers: IHeaders = {}; - await this.request(url, { type: 'DELETE', headers }, [], CancellationToken.None); + await this.request(url, { type: 'DELETE', headers, callSite: 'userDataSync.deleteResource' }, [], CancellationToken.None); } async deleteResources(): Promise { @@ -342,7 +342,7 @@ export class UserDataSyncStoreClient extends Disposable { const url = joinPath(this.userDataSyncStoreUrl, 'resource').toString(); const headers: IHeaders = { 'Content-Type': Mimes.text }; - await this.request(url, { type: 'DELETE', headers }, [], CancellationToken.None); + await this.request(url, { type: 'DELETE', headers, callSite: 'userDataSync.deleteResources' }, [], CancellationToken.None); } async readResource(resource: ServerResource, oldValue: IUserData | null, collection?: string, headers: IHeaders = {}): Promise { @@ -358,7 +358,7 @@ export class UserDataSyncStoreClient extends Disposable { headers['If-None-Match'] = oldValue.ref; } - const context = await this.request(url, { type: 'GET', headers }, [304], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.readResource' }, [304], CancellationToken.None); let userData: IUserData | null = null; if (context.res.statusCode === 304) { @@ -394,7 +394,7 @@ export class UserDataSyncStoreClient extends Disposable { headers['If-Match'] = ref; } - const context = await this.request(url, { type: 'POST', data, headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'POST', data, headers, callSite: 'userDataSync.writeResource' }, [], CancellationToken.None); const newRef = context.res.headers['etag']; if (!newRef) { @@ -417,7 +417,7 @@ export class UserDataSyncStoreClient extends Disposable { headers['If-None-Match'] = oldValue.ref; } - const context = await this.request(url, { type: 'GET', headers }, [304], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.manifest' }, [304], CancellationToken.None); let manifest: IUserDataManifest | null = null; if (context.res.statusCode === 304) { @@ -481,7 +481,7 @@ export class UserDataSyncStoreClient extends Disposable { headers = { ...headers }; headers['Content-Type'] = 'application/json'; - const context = await this.request(url, { type: 'GET', headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.getLatestData' }, [], CancellationToken.None); if (!isSuccess(context)) { throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, url, UserDataSyncErrorCode.EmptyResponse, context.res.statusCode, context.res.headers[HEADER_OPERATION_ID]); @@ -530,7 +530,7 @@ export class UserDataSyncStoreClient extends Disposable { const url = joinPath(this.userDataSyncStoreUrl, 'download').toString(); const headers: IHeaders = {}; - const context = await this.request(url, { type: 'GET', headers }, [], CancellationToken.None); + const context = await this.request(url, { type: 'GET', headers, callSite: 'userDataSync.getActivityData' }, [], CancellationToken.None); if (!isSuccess(context)) { throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, url, UserDataSyncErrorCode.EmptyResponse, context.res.statusCode, context.res.headers[HEADER_OPERATION_ID]); diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts index e53e777741648..058eab04cfb52 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncClient.ts @@ -6,7 +6,7 @@ import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IStringDictionary } from '../../../../base/common/collections.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { FormattingOptions } from '../../../../base/common/jsonFormatter.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -26,7 +26,7 @@ import { TestInstantiationService } from '../../../instantiation/test/common/ins import { ILogService, NullLogService } from '../../../log/common/log.js'; import product from '../../../product/common/product.js'; import { IProductService } from '../../../product/common/productService.js'; -import { AuthInfo, Credentials, IRequestService } from '../../../request/common/request.js'; +import { AuthInfo, Credentials, IRequestCompleteEvent, IRequestService } from '../../../request/common/request.js'; import { InMemoryStorageService, IStorageService } from '../../../storage/common/storage.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -181,6 +181,8 @@ export class UserDataSyncTestServer implements IRequestService { _serviceBrand: undefined; + readonly onDidCompleteRequest = Event.None as Event; + readonly url: string = 'http://host:3000'; private session: string | null = null; private readonly collections = new Map>(); diff --git a/src/vs/platform/userDataSync/test/common/userDataSyncStoreService.test.ts b/src/vs/platform/userDataSync/test/common/userDataSyncStoreService.test.ts index 4f720f7d23198..847a38470899f 100644 --- a/src/vs/platform/userDataSync/test/common/userDataSyncStoreService.test.ts +++ b/src/vs/platform/userDataSync/test/common/userDataSyncStoreService.test.ts @@ -13,7 +13,7 @@ import { runWithFakedTimers } from '../../../../base/test/common/timeTravelSched import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { IRequestService } from '../../../request/common/request.js'; +import { IRequestCompleteEvent, IRequestService } from '../../../request/common/request.js'; import { IUserDataSyncStoreService, SyncResource, UserDataSyncErrorCode, UserDataSyncStoreError } from '../../common/userDataSync.js'; import { RequestsSession, UserDataSyncStoreService } from '../../common/userDataSyncStoreService.js'; import { UserDataSyncClient, UserDataSyncTestServer } from './userDataSyncClient.js'; @@ -412,6 +412,7 @@ suite('UserDataSyncRequestsSession', () => { const requestService: IRequestService = { _serviceBrand: undefined, + onDidCompleteRequest: Event.None as Event, async request() { return { res: { headers: {} }, stream: newWriteableBufferStream() }; }, async resolveProxy() { return undefined; }, async lookupAuthorization() { return undefined; }, @@ -424,10 +425,10 @@ suite('UserDataSyncRequestsSession', () => { test('too many requests are thrown when limit exceeded', async () => { const testObject = new RequestsSession(1, 500, requestService, new NullLogService()); - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); try { - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); } catch (error) { assert.ok(error instanceof UserDataSyncStoreError); assert.strictEqual((error).code, UserDataSyncErrorCode.LocalTooManyRequests); @@ -438,19 +439,19 @@ suite('UserDataSyncRequestsSession', () => { test('requests are handled after session is expired', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const testObject = new RequestsSession(1, 100, requestService, new NullLogService()); - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); await timeout(125); - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); })); test('too many requests are thrown after session is expired', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const testObject = new RequestsSession(1, 100, requestService, new NullLogService()); - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); await timeout(125); - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); try { - await testObject.request('url', {}, CancellationToken.None); + await testObject.request('url', { callSite: 'test' }, CancellationToken.None); } catch (error) { assert.ok(error instanceof UserDataSyncStoreError); assert.strictEqual((error).code, UserDataSyncErrorCode.LocalTooManyRequests); diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts index fccddba015651..c1dd46b4c1771 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -245,8 +245,12 @@ export class UtilityProcess extends Disposable { const serviceName = `${this.configuration.type}-${this.id}`; const modulePath = FileAccess.asFileUri('bootstrap-fork.js').fsPath; const args = this.configuration.args ?? []; - const execArgv = this.configuration.execArgv ?? []; + const execArgv = [...(this.configuration.execArgv ?? [])]; const allowLoadingUnsignedLibraries = this.configuration.allowLoadingUnsignedLibraries; + const jsFlags = app.commandLine.getSwitchValue('js-flags'); + if (jsFlags) { + execArgv.push(`--js-flags=${jsFlags}`); + } const respondToAuthRequestsFromMainProcess = this.configuration.respondToAuthRequestsFromMainProcess; const stdio = 'pipe'; const env = this.createEnv(configuration); diff --git a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts index ae70e341c0c4e..f5d2edb27967e 100644 --- a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts @@ -31,7 +31,7 @@ export class SharedWebContentExtractorService implements ISharedWebContentExtrac const content = VSBuffer.wrap(await (response as unknown as { bytes: () => Promise> } /* workaround https://github.com/microsoft/TypeScript/issues/61826 */).bytes()); return content; } catch (err) { - console.log(err); + console.error(err); return undefined; } } diff --git a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts index 5085922319585..f4ec49d643be5 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; -import { AXNode, AXProperty, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; +import { AXNode, AXProperty, AXPropertyName, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; suite('CDP Accessibility Domain', () => { @@ -17,10 +17,9 @@ suite('CDP Accessibility Domain', () => { return { type, value }; } - function createAXProperty(name: string, value: any, type: AXValueType = 'string'): AXProperty { + function createAXProperty(name: AXPropertyName, value: any, type: AXValueType = 'string'): AXProperty { return { - // eslint-disable-next-line local/code-no-any-casts - name: name as any, + name, value: createAXValue(type, value) }; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 7455376e3f45c..07551319546ce 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { getMarks, mark } from '../../../base/common/performance.js'; -import { isTahoeOrNewer, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { isTahoeOrNewer, isLinux, isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { release } from 'os'; @@ -702,11 +702,16 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this.windowState = state; this.logService.trace('window#ctor: using window state', state); - const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, { + const webPreferences: electron.WebPreferences = { preload: FileAccess.asFileUri('vs/base/parts/sandbox/electron-browser/preload.js').fsPath, additionalArguments: [`--vscode-window-config=${this.configObjectUrl.resource.toString()}`], - v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none', - }); + v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none' + }; + if ((process as INodeProcess).isEmbeddedApp) { + webPreferences.backgroundThrottling = false; // disable for sub-app + } + + const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, webPreferences); // Create the browser window mark('code/willCreateCodeBrowserWindow'); diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 50b1321e83e30..75bcee0c678ad 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -7,7 +7,7 @@ import electron, { Display, Rectangle } from 'electron'; import { Color } from '../../../base/common/color.js'; import { Event } from '../../../base/common/event.js'; import { join } from '../../../base/common/path.js'; -import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; @@ -177,8 +177,15 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt if (isLinux) { options.icon = join(environmentMainService.appRoot, 'resources/linux/code.png'); // always on Linux - } else if (isWindows && !environmentMainService.isBuilt) { - options.icon = join(environmentMainService.appRoot, 'resources/win32/code_150x150.png'); // only when running out of sources on Windows + } else if (isWindows) { + if (!environmentMainService.isBuilt) { + options.icon = join(environmentMainService.appRoot, 'resources/win32/code_150x150.png'); // only when running out of sources on Windows + } else if ((process as INodeProcess).isEmbeddedApp) { + // For sub app the proxy executable acts as a launcher to the main executable whose + // icon will be used when creating windows if the following override is not set. + // This avoids sharing icon with the main application. + options.icon = join(environmentMainService.appRoot, 'resources/win32/sessions.ico'); + } } if (isMacintosh) { diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 9771a607efe92..ec4b29a101be7 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -1561,7 +1561,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic os: { release: release(), hostname: hostname(), arch: arch() }, autoDetectHighContrast: windowConfig?.autoDetectHighContrast ?? true, - autoDetectColorScheme: windowConfig?.autoDetectColorScheme ?? false, + autoDetectColorScheme: windowConfig?.autoDetectColorScheme ?? this.themeMainService.isAutoDetectColorScheme(), accessibilitySupport: app.accessibilitySupportEnabled, colorScheme: this.themeMainService.getColorScheme(), policiesData: this.policyService.serialize(), diff --git a/src/vs/platform/workspace/common/workspace.ts b/src/vs/platform/workspace/common/workspace.ts index a0459d077e6ea..691e1a3bf2e89 100644 --- a/src/vs/platform/workspace/common/workspace.ts +++ b/src/vs/platform/workspace/common/workspace.ts @@ -74,6 +74,11 @@ export interface IWorkspaceContextService { * Returns if the provided resource is inside the workspace or not. */ isInsideWorkspace(resource: URI): boolean; + + /** + * Return `true` if the current workspace has data (e.g. folders or a workspace configuration) that can be sent to the extension host, otherwise `false`. + */ + hasWorkspaceData(): boolean; } export interface IResolvedWorkspace extends IWorkspaceIdentifier, IBaseWorkspace { diff --git a/src/vs/server/node/remoteAgentEnvironmentImpl.ts b/src/vs/server/node/remoteAgentEnvironmentImpl.ts index 6505a5aa7d8ac..640c74695a52f 100644 --- a/src/vs/server/node/remoteAgentEnvironmentImpl.ts +++ b/src/vs/server/node/remoteAgentEnvironmentImpl.ts @@ -112,6 +112,7 @@ export class RemoteAgentEnvironmentChannel implements IServerChannel { pid: process.pid, connectionToken: (this._connectionToken.type !== ServerConnectionTokenType.None ? this._connectionToken.value : ''), appRoot: URI.file(this._environmentService.appRoot), + execPath: process.execPath, tmpDir: this._environmentService.tmpDir, settingsPath: this._environmentService.machineSettingsResource, mcpResource: this._environmentService.mcpResource, diff --git a/src/vs/server/node/remoteExtensionHostAgentServer.ts b/src/vs/server/node/remoteExtensionHostAgentServer.ts index da7e417cd5cda..5c4f3c2a1d262 100644 --- a/src/vs/server/node/remoteExtensionHostAgentServer.ts +++ b/src/vs/server/node/remoteExtensionHostAgentServer.ts @@ -13,7 +13,7 @@ import { VSBuffer } from '../../base/common/buffer.js'; import { CharCode } from '../../base/common/charCode.js'; import { isSigPipeError, onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; import { isEqualOrParent } from '../../base/common/extpath.js'; -import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../base/common/lifecycle.js'; import { connectionTokenQueryName, FileAccess, getServerProductSegment, Schemas } from '../../base/common/network.js'; import { dirname, join } from '../../base/common/path.js'; import * as perf from '../../base/common/performance.js'; @@ -37,12 +37,11 @@ import { ExtensionHostConnection } from './extensionHostConnection.js'; import { ManagementConnection } from './remoteExtensionManagement.js'; import { determineServerConnectionToken, requestHasValidConnectionToken as httpRequestHasValidConnectionToken, ServerConnectionToken, ServerConnectionTokenParseError, ServerConnectionTokenType } from './serverConnectionToken.js'; import { IServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; +import { IServerLifetimeService } from './serverLifetimeService.js'; import { setupServerServices, SocketServer } from './serverServices.js'; import { CacheControl, serveError, serveFile, WebClientServer } from './webClientServer.js'; const require = createRequire(import.meta.url); -const SHUTDOWN_TIMEOUT = 5 * 60 * 1000; - declare namespace vsda { // the signer is a native module that for historical reasons uses a lower case class name // eslint-disable-next-line @typescript-eslint/naming-convention @@ -62,6 +61,7 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { private readonly _extHostConnections: { [reconnectionToken: string]: ExtensionHostConnection }; private readonly _managementConnections: { [reconnectionToken: string]: ManagementConnection }; private readonly _allReconnectionTokens: Set; + private readonly _extHostLifetimeTokens = this._register(new DisposableMap()); private readonly _webClientServer: WebClientServer | null; private readonly _webEndpointOriginChecker: WebEndpointOriginChecker; private readonly _reconnectionGraceTime: number; @@ -69,8 +69,6 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { private readonly _serverBasePath: string | undefined; private readonly _serverProductPath: string; - private shutdownTimer: Timeout | undefined; - constructor( private readonly _socketServer: SocketServer, private readonly _connectionToken: ServerConnectionToken, @@ -81,6 +79,7 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { @IProductService private readonly _productService: IProductService, @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IServerLifetimeService private readonly _serverLifetimeService: IServerLifetimeService, ) { super(); this._webEndpointOriginChecker = WebEndpointOriginChecker.create(this._productService); @@ -101,8 +100,6 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { ); this._logService.info(`Extension host agent started.`); this._reconnectionGraceTime = this._environmentService.reconnectionGraceTime; - - this._waitThenShutdown(true); } public async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { @@ -139,7 +136,7 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { // Delay shutdown if (pathname === '/delay-shutdown') { - this._delayShutdown(); + this._serverLifetimeService.delay(); res.writeHead(200); return void res.end('OK'); } @@ -478,10 +475,11 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { const con = this._instantiationService.createInstance(ExtensionHostConnection, reconnectionToken, remoteAddress, socket, dataChunk); this._extHostConnections[reconnectionToken] = con; this._allReconnectionTokens.add(reconnectionToken); + this._extHostLifetimeTokens.set(reconnectionToken, this._serverLifetimeService.active(`ExtensionHost:${reconnectionToken.substring(0, 8)}`)); con.onClose(() => { con.dispose(); delete this._extHostConnections[reconnectionToken]; - this._onDidCloseExtHostConnection(); + this._extHostLifetimeTokens.deleteAndDispose(reconnectionToken); }); con.start(startParams); } @@ -555,72 +553,6 @@ class RemoteExtensionHostAgentServer extends Disposable implements IServerAPI { startParams.break = undefined; return Promise.resolve(startParams); } - - private async _onDidCloseExtHostConnection(): Promise { - if (!this._environmentService.args['enable-remote-auto-shutdown']) { - return; - } - - this._cancelShutdown(); - - const hasActiveExtHosts = !!Object.keys(this._extHostConnections).length; - if (!hasActiveExtHosts) { - console.log('Last EH closed, waiting before shutting down'); - this._logService.info('Last EH closed, waiting before shutting down'); - this._waitThenShutdown(); - } - } - - private _waitThenShutdown(initial = false): void { - if (!this._environmentService.args['enable-remote-auto-shutdown']) { - return; - } - - if (this._environmentService.args['remote-auto-shutdown-without-delay'] && !initial) { - this._shutdown(); - } else { - this.shutdownTimer = setTimeout(() => { - this.shutdownTimer = undefined; - - this._shutdown(); - }, SHUTDOWN_TIMEOUT); - } - } - - private _shutdown(): void { - const hasActiveExtHosts = !!Object.keys(this._extHostConnections).length; - if (hasActiveExtHosts) { - console.log('New EH opened, aborting shutdown'); - this._logService.info('New EH opened, aborting shutdown'); - return; - } else { - console.log('Last EH closed, shutting down'); - this._logService.info('Last EH closed, shutting down'); - this.dispose(); - process.exit(0); - } - } - - /** - * If the server is in a shutdown timeout, cancel it and start over - */ - private _delayShutdown(): void { - if (this.shutdownTimer) { - console.log('Got delay-shutdown request while in shutdown timeout, delaying'); - this._logService.info('Got delay-shutdown request while in shutdown timeout, delaying'); - this._cancelShutdown(); - this._waitThenShutdown(); - } - } - - private _cancelShutdown(): void { - if (this.shutdownTimer) { - console.log('Cancelling previous shutdown timeout'); - this._logService.info('Cancelling previous shutdown timeout'); - clearTimeout(this.shutdownTimer); - this.shutdownTimer = undefined; - } - } } export interface IServerAPI { @@ -834,6 +766,7 @@ export async function createServer(address: string | net.AddressInfo | null, arg output += `\n`; console.log(output); } + return remoteExtensionHostAgentServer; } diff --git a/src/vs/server/node/remoteExtensionManagement.ts b/src/vs/server/node/remoteExtensionManagement.ts index df587cf746828..4a38e83ea25a0 100644 --- a/src/vs/server/node/remoteExtensionManagement.ts +++ b/src/vs/server/node/remoteExtensionManagement.ts @@ -8,6 +8,7 @@ import { ILogService } from '../../platform/log/common/log.js'; import { Emitter, Event } from '../../base/common/event.js'; import { VSBuffer } from '../../base/common/buffer.js'; import { ProcessTimeRunOnceScheduler } from '../../base/common/async.js'; +import { IDisposable } from '../../base/common/lifecycle.js'; function printTime(ms: number): string { let h = 0; @@ -45,6 +46,7 @@ export class ManagementConnection { private _disposed: boolean; private _disconnectRunner1: ProcessTimeRunOnceScheduler; private _disconnectRunner2: ProcessTimeRunOnceScheduler; + private readonly _socketCloseListener: IDisposable; constructor( private readonly _logService: ILogService, @@ -69,11 +71,11 @@ export class ManagementConnection { this._cleanResources(); }, this._reconnectionShortGraceTime); - this.protocol.onDidDispose(() => { + Event.once(this.protocol.onDidDispose)(() => { this._log(`The client has disconnected gracefully, so the connection will be disposed.`); this._cleanResources(); }); - this.protocol.onSocketClose(() => { + this._socketCloseListener = this.protocol.onSocketClose(() => { this._log(`The client has disconnected, will wait for reconnection ${printTime(this._reconnectionGraceTime)} before disposing...`); // The socket has closed, let's give the renderer a certain amount of time to reconnect this._disconnectRunner1.schedule(); @@ -106,6 +108,7 @@ export class ManagementConnection { this._disposed = true; this._disconnectRunner1.dispose(); this._disconnectRunner2.dispose(); + this._socketCloseListener.dispose(); const socket = this.protocol.getSocket(); this.protocol.sendDisconnect(); this.protocol.dispose(); diff --git a/src/vs/server/node/remoteTerminalChannel.ts b/src/vs/server/node/remoteTerminalChannel.ts index a455eba42679c..ab6f98f02561c 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -226,7 +226,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel< const activeWorkspaceFolder = args.activeWorkspaceFolder ? reviveWorkspaceFolder(args.activeWorkspaceFolder) : undefined; const activeFileResource = args.activeFileResource ? URI.revive(uriTransformer.transformIncoming(args.activeFileResource)) : undefined; const customVariableResolver = new CustomVariableResolver(baseEnv, workspaceFolders, activeFileResource, args.resolvedVariables, this._extensionManagementService); - const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, process.env, customVariableResolver); + const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, baseEnv, customVariableResolver); // Get the initial cwd const initialCwd = await terminalEnvironment.getCwd(shellLaunchConfig, os.homedir(), variableResolver, activeWorkspaceFolder?.uri, args.configuration['terminal.integrated.cwd'], this._logService); diff --git a/src/vs/server/node/serverAgentHostManager.ts b/src/vs/server/node/serverAgentHostManager.ts new file mode 100644 index 0000000000000..dff86352208c4 --- /dev/null +++ b/src/vs/server/node/serverAgentHostManager.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../base/common/event.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../../platform/agentHost/common/agent.js'; +import { AgentHostIpcChannels, IAgentService } from '../../platform/agentHost/common/agentService.js'; +import { createDecorator } from '../../platform/instantiation/common/instantiation.js'; +import { ILogService, ILoggerService } from '../../platform/log/common/log.js'; +import { RemoteLoggerChannelClient } from '../../platform/log/common/logIpc.js'; +import { IServerLifetimeService } from './serverLifetimeService.js'; + +export const IServerAgentHostManager = createDecorator('serverAgentHostManager'); + +/** + * Server-specific agent host manager. Eagerly starts the agent host process, + * handles crash recovery, and tracks both active agent sessions and connected + * WebSocket clients via {@link IServerLifetimeService} to keep the server + * alive while either signal is active. + * + * The lifetime token is held when active sessions > 0 OR connected clients > 0. + * It is released only when both are zero. + */ +export interface IServerAgentHostManager { + readonly _serviceBrand: undefined; +} + +/** + * Proxy interface for the connection tracker IPC channel exposed by the agent + * host process. This is NOT part of the agent host protocol -- it is a + * server-only process-management concern. + */ +interface IConnectionTrackerService { + readonly onDidChangeConnectionCount: Event; +} + +enum Constants { + MaxRestarts = 5, +} + +export class ServerAgentHostManager extends Disposable implements IServerAgentHostManager { + declare readonly _serviceBrand: undefined; + + private _restartCount = 0; + + /** Lifetime token held while sessions are active or clients are connected. */ + private readonly _lifetimeToken = this._register(new MutableDisposable()); + + private _hasActiveSessions = false; + private _connectionCount = 0; + + constructor( + private readonly _starter: IAgentHostStarter, + @ILogService private readonly _logService: ILogService, + @ILoggerService private readonly _loggerService: ILoggerService, + @IServerLifetimeService private readonly _serverLifetimeService: IServerLifetimeService, + ) { + super(); + this._register(this._starter); + this._start(); + } + + private _start(): void { + const connection = this._starter.start(); + + this._logService.info('ServerAgentHostManager: agent host started'); + + // Connect logger channel so agent host logs appear in the output channel + connection.store.add(new RemoteLoggerChannelClient(this._loggerService, connection.client.getChannel(AgentHostIpcChannels.Logger))); + + this._trackActiveSessions(connection); + this._trackClientConnections(connection); + + // Handle unexpected exit + connection.store.add(connection.onDidProcessExit(e => { + if (!this._store.isDisposed) { + // Both signals are gone when the process exits + this._hasActiveSessions = false; + this._connectionCount = 0; + this._lifetimeToken.clear(); + + if (this._restartCount <= Constants.MaxRestarts) { + this._logService.error(`ServerAgentHostManager: agent host terminated unexpectedly with code ${e.code}`); + this._restartCount++; + connection.store.dispose(); + this._start(); + } else { + this._logService.error(`ServerAgentHostManager: agent host terminated with code ${e.code}, giving up after ${Constants.MaxRestarts} restarts`); + } + } + })); + + this._register(toDisposable(() => connection.store.dispose())); + } + + private _trackActiveSessions(connection: IAgentHostConnection): void { + const agentService = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.AgentHost)); + connection.store.add(agentService.onDidAction(envelope => { + if (envelope.action.type === 'root/activeSessionsChanged') { + this._hasActiveSessions = envelope.action.activeSessions > 0; + this._updateLifetimeToken(); + } + })); + } + + private _trackClientConnections(connection: IAgentHostConnection): void { + const connectionTracker = ProxyChannel.toService(connection.client.getChannel(AgentHostIpcChannels.ConnectionTracker)); + connection.store.add(connectionTracker.onDidChangeConnectionCount(count => { + this._connectionCount = count; + this._updateLifetimeToken(); + })); + } + + private _updateLifetimeToken(): void { + if (this._hasActiveSessions || this._connectionCount > 0) { + this._lifetimeToken.value ??= this._serverLifetimeService.active('AgentHost'); + } else { + this._lifetimeToken.clear(); + } + } +} diff --git a/src/vs/server/node/serverEnvironmentService.ts b/src/vs/server/node/serverEnvironmentService.ts index ef423dc80fb78..bacdd289d3108 100644 --- a/src/vs/server/node/serverEnvironmentService.ts +++ b/src/vs/server/node/serverEnvironmentService.ts @@ -85,6 +85,9 @@ export const serverOptions: OptionDescriptions> = { 'remote-auto-shutdown-without-delay': { type: 'boolean' }, 'inspect-ptyhost': { type: 'string', allowEmptyValue: true }, + 'agent-host-port': { type: 'string', cat: 'o', args: 'port', description: nls.localize('agent-host-port', "The port the agent host WebSocket server should listen on.") }, + 'agent-host-path': { type: 'string', cat: 'o', args: 'path', description: nls.localize('agent-host-path', "The path to a socket file for the agent host WebSocket server to listen on.") }, + 'use-host-proxy': { type: 'boolean' }, 'without-browser-env-var': { type: 'boolean' }, 'reconnection-grace-time': { type: 'string', cat: 'o', args: 'seconds', description: nls.localize('reconnection-grace-time', "Override the reconnection grace time window in seconds. Defaults to 10800 (3 hours).") }, @@ -215,6 +218,9 @@ export interface ServerParsedArgs { 'remote-auto-shutdown-without-delay'?: boolean; 'inspect-ptyhost'?: string; + 'agent-host-port'?: string; + 'agent-host-path'?: string; + 'use-host-proxy'?: boolean; 'without-browser-env-var'?: boolean; 'reconnection-grace-time'?: string; diff --git a/src/vs/server/node/serverLifetimeService.ts b/src/vs/server/node/serverLifetimeService.ts new file mode 100644 index 0000000000000..397c3eed307b8 --- /dev/null +++ b/src/vs/server/node/serverLifetimeService.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { createDecorator } from '../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../platform/log/common/log.js'; + +export const IServerLifetimeService = createDecorator('serverLifetimeService'); + +export const SHUTDOWN_TIMEOUT = 5 * 60 * 1000; + +/** Options controlling the auto-shutdown behaviour. */ +export interface IServerLifetimeOptions { + /** When `false` (default), the server never auto-shuts down. */ + readonly enableAutoShutdown?: boolean; + /** When `true`, skip the 5-minute grace period on non-initial shutdowns. */ + readonly shutdownWithoutDelay?: boolean; +} + +/** + * Tracks active consumers (extension hosts, agent sessions, etc.) that keep + * the server alive. When auto-shutdown is enabled, the service manages a + * shutdown timer and fires {@link onDidShutdownRequested} when it is time for + * the process to exit. + */ +export interface IServerLifetimeService { + readonly _serviceBrand: undefined; + + /** + * Marks a consumer as active. The server will not auto-shutdown until the + * returned {@link IDisposable} is disposed. + */ + active(consumer: string): IDisposable; + + /** + * Delays the auto-shutdown timer. If the server is currently in a shutdown + * timeout (all consumers inactive), the timer is reset. + */ + delay(): void; + + /** Whether any consumer is currently active. */ + readonly hasActiveConsumers: boolean; +} + +export class ServerLifetimeService extends Disposable implements IServerLifetimeService { + declare readonly _serviceBrand: undefined; + + private readonly _consumers = new Map(); + private _totalCount = 0; + private _shutdownTimer: ReturnType | undefined; + + constructor( + private readonly _options: IServerLifetimeOptions, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + if (this._options.enableAutoShutdown) { + // Start initial shutdown timer (no clients connected yet) + this._scheduleShutdown(true); + } + } + + get hasActiveConsumers(): boolean { + return this._totalCount > 0; + } + + active(consumer: string): IDisposable { + const wasEmpty = this._totalCount === 0; + const current = this._consumers.get(consumer) ?? 0; + this._consumers.set(consumer, current + 1); + this._totalCount++; + + this._logService.debug(`ServerLifetime: consumer '${consumer}' active (total: ${this._totalCount})`); + + if (wasEmpty) { + this._cancelShutdown(); + } + + let disposed = false; + return toDisposable(() => { + if (disposed) { + return; + } + disposed = true; + + const count = this._consumers.get(consumer); + if (count !== undefined) { + if (count <= 1) { + this._consumers.delete(consumer); + } else { + this._consumers.set(consumer, count - 1); + } + } + this._totalCount--; + + this._logService.debug(`ServerLifetime: consumer '${consumer}' inactive (total: ${this._totalCount})`); + + if (this._totalCount === 0 && this._options.enableAutoShutdown) { + this._scheduleShutdown(false); + } + }); + } + + delay(): void { + if (this._shutdownTimer) { + this._logService.debug('ServerLifetime: delay requested, resetting shutdown timer'); + this._cancelShutdown(); + this._scheduleShutdown(false); + } + } + + private _scheduleShutdown(initial: boolean): void { + if (this._options.shutdownWithoutDelay && !initial) { + this._tryShutdown(); + } else { + this._logService.debug('ServerLifetime: scheduling shutdown timer'); + this._shutdownTimer = setTimeout(() => { + this._shutdownTimer = undefined; + this._tryShutdown(); + }, SHUTDOWN_TIMEOUT); + } + } + + private _tryShutdown(): void { + if (this._totalCount > 0) { + this._logService.debug('ServerLifetime: consumer became active, aborting shutdown'); + return; + } + console.log('All consumers inactive, shutting down'); + this._logService.info('ServerLifetime: all consumers inactive, shutting down'); + this.dispose(); + process.exit(0); + } + + private _cancelShutdown(): void { + if (this._shutdownTimer) { + this._logService.debug('ServerLifetime: cancelling shutdown timer'); + clearTimeout(this._shutdownTimer); + this._shutdownTimer = undefined; + } + } +} diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index d37663dfefb4c..c562b01755078 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -56,7 +56,7 @@ import { ServerTelemetryChannel } from '../../platform/telemetry/common/remoteTe import { IServerTelemetryService, ServerNullTelemetryService, ServerTelemetryService } from '../../platform/telemetry/common/serverTelemetryService.js'; import { RemoteTerminalChannel } from './remoteTerminalChannel.js'; import { createURITransformer } from '../../base/common/uriTransformer.js'; -import { ServerConnectionToken } from './serverConnectionToken.js'; +import { ServerConnectionToken, ServerConnectionTokenType } from './serverConnectionToken.js'; import { ServerEnvironmentService, ServerParsedArgs } from './serverEnvironmentService.js'; import { REMOTE_TERMINAL_CHANNEL_NAME } from '../../workbench/contrib/terminal/common/remote/remoteTerminalChannel.js'; import { REMOTE_FILE_SYSTEM_CHANNEL_NAME } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; @@ -77,6 +77,9 @@ import { RemoteExtensionsScannerChannel, RemoteExtensionsScannerService } from ' import { RemoteExtensionsScannerChannelName } from '../../platform/remote/common/remoteExtensionsScanner.js'; import { RemoteUserDataProfilesServiceChannel } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; import { NodePtyHostStarter } from '../../platform/terminal/node/nodePtyHostStarter.js'; +import { NodeAgentHostStarter } from '../../platform/agentHost/node/nodeAgentHostStarter.js'; +import { ServerAgentHostManager } from './serverAgentHostManager.js'; +import { IServerLifetimeService, ServerLifetimeService } from './serverLifetimeService.js'; import { CSSDevelopmentService, ICSSDevelopmentService } from '../../platform/cssDev/node/cssDevService.js'; import { AllowedExtensionsService } from '../../platform/extensionManagement/common/allowedExtensionsService.js'; import { TelemetryLogAppender } from '../../platform/telemetry/common/telemetryLogAppender.js'; @@ -228,6 +231,23 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const ptyHostService = instantiationService.createInstance(PtyHostService, ptyHostStarter); services.set(IPtyService, ptyHostService); + const serverLifetimeService = instantiationService.createInstance(ServerLifetimeService, { + enableAutoShutdown: !!args['enable-remote-auto-shutdown'], + shutdownWithoutDelay: !!args['remote-auto-shutdown-without-delay'], + }); + services.set(IServerLifetimeService, serverLifetimeService); + + if (args['agent-host-port'] || args['agent-host-path']) { + const agentHostStarter = instantiationService.createInstance(NodeAgentHostStarter); + agentHostStarter.setWebSocketConfig({ + port: args['agent-host-port'], + socketPath: args['agent-host-path'], + host: args.host || 'localhost', + connectionToken: connectionToken.type === ServerConnectionTokenType.Mandatory ? connectionToken.value : undefined, + }); + disposables.add(instantiationService.createInstance(ServerAgentHostManager, agentHostStarter)); + } + services.set(IAllowedMcpServersService, new SyncDescriptor(AllowedMcpServersService)); services.set(IMcpResourceScannerService, new SyncDescriptor(McpResourceScannerService)); services.set(IMcpGalleryService, new SyncDescriptor(McpGalleryService)); diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 7881ad0393d70..c920340ed1304 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -73,10 +73,29 @@ export async function serveFile(filePath: string, cacheControl: CacheControl, lo responseHeaders['Content-Type'] = textMimeType[extname(filePath)] || getMediaMime(filePath) || 'text/plain'; - res.writeHead(200, responseHeaders); - - // Data - createReadStream(filePath).pipe(res); + // Create the stream first and wait for it to open before sending + // headers so that errors (e.g. ENOENT race) can still produce a + // proper 404 response instead of aborting a half-sent 200. + const fileStream = createReadStream(filePath); + await new Promise((resolve, reject) => { + fileStream.on('error', reject); + fileStream.on('open', () => { + // File opened successfully - send headers and pipe + res.writeHead(200, responseHeaders); + fileStream.pipe(res); + // Destroy the read stream if the response is closed prematurely + // (e.g. client disconnect) to avoid leaking the file descriptor. + res.once('close', () => fileStream.destroy()); + fileStream.on('end', resolve); + // Replace the initial error handler now that headers are sent + fileStream.removeAllListeners('error'); + fileStream.on('error', error => { + logService.error(error); + console.error(error.toString()); + res.destroy(); + }); + }); + }); } catch (error) { if (error.code !== 'ENOENT') { logService.error(error); @@ -206,7 +225,8 @@ export class WebClientServer { const context = await this._requestService.request({ type: 'GET', url: uri.toString(true), - headers + headers, + callSite: 'webClientServer.fetchAndWriteFile' }, CancellationToken.None); const status = context.res.statusCode || 500; diff --git a/src/vs/server/test/node/serverAgentHostManager.test.ts b/src/vs/server/test/node/serverAgentHostManager.test.ts new file mode 100644 index 0000000000000..81ec4b695e631 --- /dev/null +++ b/src/vs/server/test/node/serverAgentHostManager.test.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { IChannel, IChannelClient } from '../../../base/parts/ipc/common/ipc.js'; +import { IAgentHostConnection, IAgentHostStarter } from '../../../platform/agentHost/common/agent.js'; +import { AgentHostIpcChannels } from '../../../platform/agentHost/common/agentService.js'; +import { NullLogService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { ServerAgentHostManager } from '../../node/serverAgentHostManager.js'; +import { IServerLifetimeService } from '../../node/serverLifetimeService.js'; + +// ---- Mock helpers ----------------------------------------------------------- + +class MockChannel implements IChannel { + private readonly _listeners = new Map>(); + private readonly _callResults = new Map(); + + getEmitter(event: string): Emitter { + let emitter = this._listeners.get(event); + if (!emitter) { + emitter = new Emitter(); + this._listeners.set(event, emitter); + } + return emitter; + } + + setCallResult(command: string, value: unknown): void { + this._callResults.set(command, value); + } + + call(command: string, _arg?: unknown): Promise { + return Promise.resolve((this._callResults.get(command) ?? undefined) as T); + } + + listen(event: string, _arg?: unknown): Event { + return this.getEmitter(event).event as Event; + } + + dispose(): void { + for (const emitter of this._listeners.values()) { + emitter.dispose(); + } + this._listeners.clear(); + } +} + +class MockAgentHostStarter implements IAgentHostStarter { + private readonly _onDidProcessExit = new Emitter<{ code: number; signal: string }>(); + + readonly agentHostChannel = new MockChannel(); + readonly loggerChannel: MockChannel; + readonly connectionTrackerChannel = new MockChannel(); + + constructor() { + this.loggerChannel = new MockChannel(); + this.loggerChannel.setCallResult('getRegisteredLoggers', []); + } + + start(): IAgentHostConnection { + const store = new DisposableStore(); + const client: IChannelClient = { + getChannel: (name: string): T => { + switch (name) { + case AgentHostIpcChannels.AgentHost: + return this.agentHostChannel as unknown as T; + case AgentHostIpcChannels.Logger: + return this.loggerChannel as unknown as T; + case AgentHostIpcChannels.ConnectionTracker: + return this.connectionTrackerChannel as unknown as T; + default: + throw new Error(`Unknown channel: ${name}`); + } + }, + }; + return { + client, + store, + onDidProcessExit: this._onDidProcessExit.event, + }; + } + + fireProcessExit(code: number): void { + this._onDidProcessExit.fire({ code, signal: '' }); + } + + dispose(): void { + this._onDidProcessExit.dispose(); + this.agentHostChannel.dispose(); + this.loggerChannel.dispose(); + this.connectionTrackerChannel.dispose(); + } +} + +class MockServerLifetimeService implements IServerLifetimeService { + declare readonly _serviceBrand: undefined; + + private _activeCount = 0; + + get hasActiveConsumers(): boolean { + return this._activeCount > 0; + } + + active(_consumer: string): IDisposable { + this._activeCount++; + return toDisposable(() => { this._activeCount--; }); + } + + delay(): void { } +} + +suite('ServerAgentHostManager', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + let starter: MockAgentHostStarter; + let lifetimeService: MockServerLifetimeService; + + setup(() => { + starter = new MockAgentHostStarter(); + lifetimeService = new MockServerLifetimeService(); + }); + + function createManager(): ServerAgentHostManager { + return ds.add(new ServerAgentHostManager( + starter, + new NullLogService(), + ds.add(new NullLoggerService()), + lifetimeService, + )); + } + + function fireActiveSessions(count: number): void { + starter.agentHostChannel.getEmitter('onDidAction').fire({ + action: { type: 'root/activeSessionsChanged', activeSessions: count }, + serverSeq: 1, + origin: undefined, + }); + } + + function fireConnectionCount(count: number): void { + starter.connectionTrackerChannel.getEmitter('onDidChangeConnectionCount').fire(count); + } + + test('no lifetime token initially', () => { + createManager(); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('acquires token when sessions become active', () => { + createManager(); + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('acquires token when clients connect (no active sessions)', () => { + createManager(); + fireConnectionCount(2); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + }); + + test('releases token only when both sessions and connections are zero', () => { + createManager(); + + // Sessions active, no connections + fireActiveSessions(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections appear too + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Sessions go idle, but connections remain + fireActiveSessions(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + // Connections drop to zero -- now both are idle + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('releases token only when connections drop after sessions already idle', () => { + createManager(); + + fireConnectionCount(3); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + fireConnectionCount(0); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); + + test('process exit resets both signals and clears token', () => { + createManager(); + + fireActiveSessions(2); + fireConnectionCount(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, true); + + starter.fireProcessExit(1); + assert.strictEqual(lifetimeService.hasActiveConsumers, false); + }); +}); diff --git a/src/vs/server/test/node/serverLifetimeService.test.ts b/src/vs/server/test/node/serverLifetimeService.test.ts new file mode 100644 index 0000000000000..54fe519412158 --- /dev/null +++ b/src/vs/server/test/node/serverLifetimeService.test.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { NullLogService } from '../../../platform/log/common/log.js'; +import { IServerLifetimeOptions, ServerLifetimeService } from '../../node/serverLifetimeService.js'; + +suite('ServerLifetimeService', () => { + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + function create(opts: IServerLifetimeOptions = {}): ServerLifetimeService { + return ds.add(new ServerLifetimeService(opts, new NullLogService())); + } + + test('starts with no active consumers', () => { + const service = create(); + assert.strictEqual(service.hasActiveConsumers, false); + }); + + test('active() marks a consumer and dispose releases it', () => { + const service = create(); + const d = service.active('test'); + assert.strictEqual(service.hasActiveConsumers, true); + d.dispose(); + assert.strictEqual(service.hasActiveConsumers, false); + }); + + test('multiple active consumers require all to dispose', () => { + const service = create(); + const d1 = service.active('a'); + const d2 = service.active('b'); + assert.strictEqual(service.hasActiveConsumers, true); + d1.dispose(); + assert.strictEqual(service.hasActiveConsumers, true); + d2.dispose(); + assert.strictEqual(service.hasActiveConsumers, false); + }); + + test('same consumer name counted multiple times', () => { + const service = create(); + const d1 = service.active('ext'); + const d2 = service.active('ext'); + assert.strictEqual(service.hasActiveConsumers, true); + d1.dispose(); + assert.strictEqual(service.hasActiveConsumers, true); + d2.dispose(); + assert.strictEqual(service.hasActiveConsumers, false); + }); + + test('dispose is idempotent', () => { + const service = create(); + const d1 = service.active('a'); + const d2 = service.active('a'); + d1.dispose(); + d1.dispose(); + assert.strictEqual(service.hasActiveConsumers, true); + d2.dispose(); + assert.strictEqual(service.hasActiveConsumers, false); + }); +}); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 9dceeac3a2b24..acb2203f9af97 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,94 +1,235 @@ # AI Customizations – Design Document -This document describes the current AI customization experience in this branch: a management editor and tree view that surface items across worktree, user, and extension storage. +This document describes the AI customization experience: a management editor and tree view that surface customization items (agents, skills, instructions, prompts, hooks, MCP servers) across workspace, user, and extension storage. -## Current Architecture +## Architecture -### File Structure (Agentic) +### File Structure + +The management editor lives in `vs/workbench` (shared between core VS Code and sessions): ``` -src/vs/sessions/contrib/aiCustomizationManagement/browser/ +src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagement.contribution.ts # Commands + context menus ├── aiCustomizationManagement.ts # IDs + context keys ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input -├── aiCustomizationListWidget.ts # Search + grouped list -├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) +├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) +├── aiCustomizationDebugPanel.ts # Debug diagnostics panel +├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl +├── customizationHarnessService.ts # Core harness service impl (agent-gated) ├── customizationCreatorService.ts # AI-guided creation flow -├── mcpListWidget.ts # MCP servers section -├── SPEC.md # Feature specification +├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer +├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) +├── pluginListWidget.ts # Agent plugins section +├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css +src/vs/workbench/contrib/chat/common/ +├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE +└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers +``` + +The tree view and overview live in `vs/sessions` (sessions window only): + +``` src/vs/sessions/contrib/aiCustomizationTreeView/browser/ ├── aiCustomizationTreeView.contribution.ts # View + actions ├── aiCustomizationTreeView.ts # IDs + menu IDs ├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationTreeViewIcons.ts # Icons -├── SPEC.md # Feature specification +├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) └── media/ └── aiCustomizationTreeView.css ``` ---- - -## Service Alignment (Required) +Sessions-specific overrides: -AI customizations must lean on existing VS Code services with well-defined interfaces. This avoids duplicated parsing logic, keeps discovery consistent across the workbench, and ensures prompt/hook behavior stays authoritative. +``` +src/vs/sessions/contrib/chat/browser/ +├── aiCustomizationWorkspaceService.ts # Sessions workspace service override +├── customizationHarnessService.ts # Sessions harness service (CLI harness only) +└── promptsService.ts # AgenticPromptsService (CLI user roots) +src/vs/sessions/contrib/sessions/browser/ +├── aiCustomizationShortcutsWidget.ts # Shortcuts widget +├── customizationCounts.ts # Source count utilities (type-aware) +└── customizationsToolbar.contribution.ts # Sidebar customization links +``` -Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. +### IAICustomizationWorkspaceService -Key services to rely on: -- Prompt discovery, parsing, and lifecycle: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) -- Active session scoping for worktree filtering: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) -- MCP servers and tool access: [src/vs/workbench/contrib/mcp/common/mcpService.ts](../workbench/contrib/mcp/common/mcpService.ts) -- MCP management and gallery: [src/vs/platform/mcp/common/mcpManagement.ts](../platform/mcp/common/mcpManagement.ts) -- Chat models and session state: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../workbench/contrib/chat/common/chatService/chatService.ts) -- File and model plumbing: [src/vs/platform/files/common/files.ts](../platform/files/common/files.ts), [src/vs/editor/common/services/resolverService.ts](../editor/common/services/resolverService.ts) +The `IAICustomizationWorkspaceService` interface controls per-window behavior: -The active worktree comes from `IActiveSessionService` and is the source of truth for any workspace/worktree scoping. +| Property / Method | Core VS Code | Sessions Window | +|----------|-------------|----------| +| `managementSections` | All sections except Models | All sections except Models | +| `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | +| `isSessionsWindow` | `false` | `true` | +| `activeProjectRoot` | First workspace folder | Active session worktree | -In the agentic workbench, prompt discovery is scoped by an agentic prompt service override that uses the active session root for workspace folders. See [src/vs/sessions/contrib/chat/browser/promptsService.ts](contrib/chat/browser/promptsService.ts). +### ICustomizationHarnessService -## Implemented Experience +A harness represents the AI execution environment that consumes customizations. +Storage answers "where did this come from?"; harness answers "who consumes it?". -### Management Editor (Current) +The service is defined in `common/customizationHarnessService.ts` which also provides: +- **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. +- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. +- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. -- A singleton editor surfaces Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, and Models. -- Prompts-based sections use a grouped list (Worktree/User/Extensions) with search, context menus, and an embedded editor. -- Embedded editor uses a full `CodeEditorWidget` and auto-commits worktree files on exit (agent session workflow). -- Creation supports manual or AI-guided flows; AI-guided creation opens a new chat with hidden system instructions. +Available harnesses: -### Tree View (Current) +| Harness | Label | Description | +|---------|-------|-------------| +| `vscode` | Local | Shows all storage sources (default in core) | +| `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | +| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | -- Unified sidebar tree with Type -> Storage -> File hierarchy. -- Auto-expands categories to reveal storage groups. -- Context menus provide Open and Run Prompt. -- Creation actions are centralized in the management editor. +In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. +In sessions, only CLI is registered (single harness, toggle bar hidden). -### Additional Surfaces (Current) +### IHarnessDescriptor -- Overview view provides counts and deep-links into the management editor. -- Management list groups by storage with empty states, git status, and path copy actions. +Key properties on the harness descriptor: ---- +| Property | Purpose | +|----------|--------| +| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | +| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | +| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | +| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | +| `requiredAgentId` | Agent ID that must be registered for harness to appear | +| `instructionFileFilter` | Filename/path patterns to filter instruction items | -## AI Feature Gating +### IStorageSourceFilter -All commands and UI must respect `ChatContextKeys.enabled`: +A unified per-type filter controlling which storage sources and user file roots are visible. +Replaces the old `visibleStorageSources`, `getVisibleStorageSources(type)`, and `excludedUserFileRoots`. ```typescript -All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. +interface IStorageSourceFilter { + sources: readonly PromptsStorage[]; // Which storage groups to display + includedUserFileRoots?: readonly URI[]; // Allowlist for user roots (undefined = all) +} ``` ---- +The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. + +**Sessions filter behavior (CLI harness):** + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | + +**Core VS Code filter behavior:** + +Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. + +CLI harness (core): + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | + +Claude harness (core): + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | + +Claude additionally applies: +- `hiddenSections: [Prompts, Plugins]` +- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` +- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) +- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension + +### Built-in Extension Grouping (Core VS Code) + +In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". + +This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: + +- **Agents**: checks `agent.source.extensionId` against the chat extension ID +- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI +- **Prompts**: checks `command.promptPath.extension?.identifier` +- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` + +The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. + +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. + +### AgenticPromptsService (Sessions) + +Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): + +- **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree +- **Built-in prompts**: Discovers bundled `.prompt.md` files from `vs/sessions/prompts/` and surfaces them with `PromptsStorage.builtin` storage type +- **User override**: Built-in prompts are omitted when a user or workspace prompt with the same name exists +- **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility +- **Hook folders**: Falls back to `.github/hooks` in the active worktree + +### Built-in Prompts + +Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. They are: + +- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/prompts')` +- Tagged with `PromptsStorage.builtin` storage type +- Shown in a "Built-in" group in the AI Customization tree view and management editor +- Filtered out when a user/workspace prompt shares the same clean name (override behavior) +- Included in storage filters for prompts and CLI-user types + +### Count Consistency + +`customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: + +| Type | Data Source | Notes | +|------|-------------|-------| +| Agents | `getCustomAgents()` | Parsed agents, not raw files | +| Skills | `findAgentSkills()` | Parsed skills with frontmatter | +| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | +| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | +| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | + +### Item Badges + +`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. The badge text is also included in search filtering. + +### Debug Panel + +Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a 4-stage pipeline view: + +1. **Raw PromptsService data** — per-storage file lists + type-specific extras +2. **After applyStorageSourceFilter** — what was removed and why +3. **Widget state** — allItems vs displayEntries with group counts +4. **Source/resolved folders** — creation targets and discovery order + +## Key Services + +- **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration +- **MCP servers**: `IMcpService` — server list, tool access +- **Active worktree**: `IActiveSessionService` — source of truth for workspace scoping (sessions only) +- **File operations**: `IFileService`, `ITextModelService` — file and model plumbing + +Browser compatibility is required — no Node.js APIs. + +## Feature Gating + +All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizationsMenu.enabled` setting. + +## Settings -## References +Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces: -- [Settings Editor](../src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts) -- [Keybindings Editor](../src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts) -- [Webview Editor](../src/vs/workbench/contrib/webviewPanel/browser/webviewEditorInput.ts) -- [AI Customization Management (agentic)](../src/vs/sessions/contrib/aiCustomizationManagement/browser/) -- [AI Customization Overview View](../src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts) -- [AI Customization Tree View (agentic)](../src/vs/sessions/contrib/aiCustomizationTreeView/browser/) -- [IPromptsService](../src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +| Setting | Default | Description | +|---------|---------|-------------| +| `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette | +| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index fea9a1c370b86..08e5510cf6e45 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -374,7 +374,7 @@ The Agent Sessions workbench uses specialized part implementations that extend t | Configuration listening | Many settings | Minimal | | Context menu actions | Full set | Simplified | | Title bar | Full support | Sidebar: `hasTitle: true` (with footer); ChatBar: `hasTitle: false`; Auxiliary Bar & Panel: `hasTitle: true` | -| Visual margins | None | Auxiliary Bar: 8px top/bottom/right (card appearance); Panel: 8px bottom/left/right (card appearance); Sidebar: 0 (flush) | +| Visual margins | None | Auxiliary Bar: 16px top/right, 18px bottom (card appearance); Panel: 18px bottom, 16px left/right (card appearance); Sidebar: 0 (flush) | ### 9.3 Part Creation @@ -440,7 +440,7 @@ The `AuxiliaryBarPart` provides a custom `DropdownWithPrimaryActionViewItem` for The `SidebarPart` includes a footer section (35px height) positioned below the pane composite content. The sidebar uses a custom `layout()` override that reduces the content height by `FOOTER_HEIGHT` and renders a `MenuWorkbenchToolBar` driven by `Menus.SidebarFooter`. The footer hosts the account widget (see Section 3.6). -On macOS native, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls, which is hidden in fullscreen mode. +On macOS native with custom titlebar, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls. The spacer is hidden in fullscreen mode and is not created when using native titlebar (since the OS renders traffic lights in its own title bar). --- @@ -640,6 +640,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-03-02 | Fixed macOS sidebar traffic light spacer to only render with custom titlebar; added `!hasNativeTitlebar()` guard to `SidebarPart.createTitleArea()` so the 70px spacer is not created when using native titlebar (traffic lights are in the OS title bar, not overlapping the sidebar) | | 2026-02-20 | Replaced custom `EditorModal` with standard `ModalEditorPart` via `MODAL_GROUP`; main editor part created but hidden; changed `workbench.editor.useModal` from boolean to enum (`off`/`some`/`all`); sessions config uses `all`; removed `editorModal.ts` and editor modal CSS | | 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | | 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md index d03cecc3dd160..9e8d73a6e3670 100644 --- a/src/vs/sessions/README.md +++ b/src/vs/sessions/README.md @@ -112,6 +112,260 @@ The Agentic Window (`Workbench`) provides a simplified, fixed-layout workbench t See [LAYOUT.md](LAYOUT.md) for the detailed layout specification. +## Sessions Provider Architecture + +The sessions window uses an extensible provider model to manage sessions. Instead of hardcoding session type logic (CLI, Cloud, Agent Host) throughout the codebase, all session behavior is encapsulated in **sessions providers** that register with a central registry. + +### Overview Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ UI Components │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ ┌──────────────┐ │ +│ │ SessionsView │ │ TitleBar │ │ NewChatWidget │ │ ChangesView │ │ +│ │ Pane │ │ Widget │ │ (workspace/type │ │ │ │ +│ │ │ │ │ │ pickers) │ │ │ │ +│ └──────┬───────┘ └──────┬───────┘ └────────┬───────────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ │ reads ISessionData observables │ │ │ +│ │ (title, status, changes, workspace, isArchived, ...) │ │ +│ └─────────────────┼────────────────────┼─────────────────────┘ │ +│ │ │ │ +│ ┌──────▼────────────────────▼──┐ │ +│ │ Sessions Management Service │ ISessionsManagementService │ +│ │ - activeSession: IObservable │ +│ │ - getSessions(): ISessionData[] │ +│ │ - openSession / createNewSession │ +│ │ - sendRequest / setSessionType │ +│ │ - onDidChangeSessions │ +│ └──────────────┬────────────────┘ │ +│ │ │ +│ ┌─────────────▼─────────────┐ │ +│ │ Sessions Providers Service │ ISessionsProvidersService │ +│ │ - registerProvider(p) │ │ +│ │ - getProviders() │ │ +│ │ - getSessions() (merged) │ │ +│ └─────────────┬──────────────┘ │ +│ │ │ +│ ┌───────────────────┼───────────────────┐ │ +│ │ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │ Copilot │ │ Remote Agent│ │ Custom │ │ +│ │ Chat │ │ Host │ │ Provider │ │ +│ │ Sessions │ │ Provider │ │ (future) │ │ +│ │ Provider │ │ │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ └─────────────┘ │ +│ │ │ │ +│ │ Each provider returns ISessionData[] │ +│ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │ Agent │ │ Agent Host │ │ +│ │ Sessions │ │ Protocol │ │ +│ │ Service │ │ │ │ +│ └─────────────┘ └─────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────────┘ + +ISessionData (reactive session facade) +┌─────────────────────────────────────────────────────────────┐ +│ sessionId: string providerId: string │ +│ resource: URI sessionType: string │ +│ icon: ThemeIcon createdAt: Date │ +├─────────────────────────────────────────────────────────────┤ +│ Observable properties (auto-update UI when changed): │ +│ │ +│ title ─────────── "Fix login bug" │ +│ status ────────── InProgress | NeedsInput | Completed │ +│ workspace ─────── { label, icon, repositories[] } │ +│ changes ───────── [{ modifiedUri, insertions, deletions }] │ +│ updatedAt ─────── Date │ +│ lastTurnEnd ───── Date | undefined │ +│ isArchived ────── boolean │ +│ isRead ────────── boolean │ +│ modelId ───────── "gpt-4o" | undefined │ +│ mode ──────────── { id, kind } | undefined │ +│ loading ───────── boolean │ +└─────────────────────────────────────────────────────────────┘ + +ISessionWorkspace (nested in ISessionData.workspace) +┌─────────────────────────────────────────────────────────────┐ +│ label: "my-app" icon: Codicon.folder │ +│ repositories: [{ │ +│ uri ──────────── file:///repo or github-remote-file:// │ +│ workingDirectory ── file:///worktree (if isolation) │ +│ detail ─────────── "feature-branch" │ +│ baseBranchProtected ── true/false │ +│ }] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Core Concepts + +#### Session Type (`ISessionType`) + +A lightweight label identifying an agent backend. Says nothing about where it runs or how it's configured. + +```typescript +// Platform-level session type (registered once) +interface ISessionType { + readonly id: string; // e.g., 'copilot-cli', 'copilot-cloud' + readonly label: string; // e.g., 'Copilot CLI', 'Cloud' + readonly icon: ThemeIcon; +} +``` + +#### Sessions Provider (`ISessionsProvider`) + +A compute environment adapter. One provider can serve multiple session types. Multiple provider instances can serve the same session type. + +```typescript +interface ISessionsProvider { + readonly id: string; // 'default-copilot', 'agenthost-hostA' + readonly label: string; + readonly sessionTypes: readonly ISessionType[]; + + // Workspace browsing + getWorkspaces(): ISessionWorkspace[]; + readonly browseActions: readonly ISessionsBrowseAction[]; + + // Session CRUD + getSessions(): ISessionData[]; + createNewSession(workspace: ISessionWorkspace): ISessionData; + sendRequest(sessionId: string, options: ISendRequestOptions): Promise; + + // Lifecycle + archiveSession(sessionId: string): Promise; + deleteSession(sessionId: string): Promise; + renameSession(sessionId: string, title: string): Promise; +} +``` + +#### Session Data (`ISessionData`) + +The universal session interface. All reactive properties are observables — UI components subscribe and update automatically. + +```typescript +interface ISessionData { + readonly sessionId: string; // Globally unique: 'providerId:localId' + readonly resource: URI; + readonly providerId: string; + readonly sessionType: string; // e.g., 'copilot-cli' + + // Reactive properties + readonly title: IObservable; + readonly status: IObservable; + readonly workspace: IObservable; + readonly changes: IObservable; + readonly isArchived: IObservable; + readonly isRead: IObservable; + readonly lastTurnEnd: IObservable; +} +``` + +### Examples + +#### Example 1: CopilotChatSessionsProvider + +The default provider wrapping existing CLI and Cloud sessions: + +``` +CopilotChatSessionsProvider +├── id: 'default-copilot' +├── sessionTypes: [CopilotCLI, CopilotCloud] +├── browseActions: +│ ├── "Browse Folders..." → file dialog +│ └── "Browse Repositories..." → GitHub repo picker +├── getSessions() → wraps IAgentSession[] as AgentSessionAdapter[] +├── createNewSession(workspace) +│ ├── file:// URI → CopilotCLISession (local background agent) +│ └── github-remote-file:// → RemoteNewSession (cloud agent) +└── sendRequest() → delegates to IChatService +``` + +#### Example 2: RemoteAgentHostSessionsProvider + +One instance per connected remote agent host: + +``` +RemoteAgentHostSessionsProvider +├── id: 'agenthost-' +├── sessionTypes: [CopilotCLI] (reuses platform type) +├── browseActions: +│ └── "Browse Remote Folders..." → remote folder picker +├── getSessions() → sessions from this specific host +└── createNewSession(workspace) + └── Creates session on the remote agent host +``` + +### Data Flow + +#### Creating a New Session + +``` +User picks workspace in WorkspacePicker + │ + ▼ +SessionsManagementService.createNewSession(providerId, workspace) + │ + ├── Finds provider by ID + ├── Calls provider.createNewSession(workspace) + │ │ + │ ▼ + │ Provider creates ISessionData + │ (e.g., CopilotCLISession or RemoteNewSession) + │ + ├── Sets as active session + └── Returns ISessionData to widget + +User types message and sends + │ + ▼ +SessionsManagementService.sendRequest(session, options) + │ + ├── Finds provider by session.providerId + ├── Calls provider.sendRequest(sessionId, options) + │ │ + │ ▼ + │ Provider creates real agent session + │ (e.g., starts CLI agent, opens cloud session) + │ + └── Returns created ISessionData (now backed by real session) +``` + +#### Session Change Events + +``` +Agent session completes a turn + │ + ▼ +AgentSessionsService fires onDidChangeSessions + │ + ▼ +CopilotChatSessionsProvider._refreshSessionCache() + ├── Diffs current sessions vs cache + ├── Updates AgentSessionAdapter observables (title, status, changes) + └── Fires onDidChangeSessions { added, removed, changed, archived } + │ + ▼ + SessionsProvidersService forwards event + │ + ▼ + SessionsManagementService forwards event + │ + ├── UI re-renders (sessions list, titlebar, changes view) + └── Context keys updated (hasChanges, isBackground, etc.) +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `contrib/sessions/common/sessionData.ts` | `ISessionData`, `ISessionWorkspace`, `ISessionRepository`, `SessionStatus` | +| `contrib/sessions/browser/sessionsProvider.ts` | `ISessionsProvider`, `ISessionType`, `ISessionsChangeEvent` | +| `contrib/sessions/browser/sessionsProvidersService.ts` | `ISessionsProvidersService` + implementation | +| `contrib/sessions/browser/sessionsManagementService.ts` | `ISessionsManagementService` — active session, routing | +| `contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` | Default Copilot provider | +| `contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts` | Remote agent host provider | + ## Adding New Functionality When adding features to the agentic window: diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md new file mode 100644 index 0000000000000..c655708c08c2b --- /dev/null +++ b/src/vs/sessions/SESSIONS_PROVIDER.md @@ -0,0 +1,453 @@ +# Sessions Provider Architecture + +## Overview + +The Sessions Provider architecture introduces an **extensible provider model** for managing agent sessions in the Sessions window. Instead of hardcoding session types and backends, multiple providers register with a central registry (`ISessionsProvidersService`), which aggregates sessions from all providers and routes actions to the correct one. + +This design allows new compute environments (remote agent hosts, cloud backends, third-party agents) to plug in without modifying core session management code. + +### Architectural Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UI Components │ +│ (SessionsView, TitleBar, NewSession, ChatWidget) │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────▼────────────┐ + │ SessionsManagementService│ ← High-level orchestration + │ (active session, send, │ context keys, provider + │ session types, etc.) │ selection + └───────────┬──────────────┘ + │ + ┌───────────▼────────────┐ + │ SessionsProvidersService │ ← Central registry & router + │ (register, aggregate, │ + │ route by session ID) │ + └──────┬──────────┬────────┘ + │ │ + ┌────────────▼──┐ ┌───▼──────────────────┐ + │ CopilotChat │ │ RemoteAgentHost │ + │ Sessions │ │ Sessions Provider │ + │ Provider │ │ (one per connection) │ + └───────┬───────┘ └──────────┬────────────┘ + │ │ + ┌───────▼───────┐ ┌──────────▼────────────┐ + │ AgentSessions │ │ Agent Host Connection │ + │ Service / │ │ (WebSocket, HTTP) │ + │ ChatService │ │ │ + └───────────────┘ └────────────────────────┘ +``` + +## Core Interfaces + +### `ISessionData` — Universal Session Facade + +**File:** `src/vs/sessions/contrib/sessions/common/sessionData.ts` + +The common session interface exposed by all providers. It is a self-contained facade — consumers should not reach back to underlying services to resolve additional data. All mutable properties are **observables** for reactive UI binding. + +| Property | Type | Description | +|----------|------|-------------| +| `sessionId` | `string` | Globally unique ID in the format `providerId:localId` | +| `resource` | `URI` | Resource URI identifying this session | +| `providerId` | `string` | ID of the owning provider | +| `sessionType` | `string` | Session type ID (e.g., `'background'`, `'cloud'`) | +| `icon` | `ThemeIcon` | Display icon | +| `createdAt` | `Date` | Creation timestamp | +| `workspace` | `IObservable` | Workspace info (repositories, label, icon) | +| `title` | `IObservable` | Display title (auto-titled or renamed) | +| `updatedAt` | `IObservable` | Last update timestamp | +| `status` | `IObservable` | Current status (Untitled, InProgress, NeedsInput, Completed, Error) | +| `changes` | `IObservable` | File changes produced by the session | +| `modelId` | `IObservable` | Selected model identifier | +| `mode` | `IObservable<{id, kind} \| undefined>` | Selected mode identifier and kind | +| `loading` | `IObservable` | Whether the session is initializing | +| `isArchived` | `IObservable` | Archive state | +| `isRead` | `IObservable` | Read/unread state | +| `description` | `IObservable` | Status description (e.g., current agent action) | +| `lastTurnEnd` | `IObservable` | When the last agent turn ended | +| `pullRequestUri` | `IObservable` | Associated pull request URI | + +#### Supporting Types + +**`ISessionWorkspace`** — Workspace information for a session: +- `label: string` — Display label (e.g., "my-app", "org/repo") +- `icon: ThemeIcon` — Workspace icon +- `repositories: ISessionRepository[]` — One or more repositories + +**`ISessionRepository`** — A repository within a workspace: +- `uri: URI` — Source repository URI (`file://` or `github-remote-file://`) +- `workingDirectory: URI | undefined` — Worktree or checkout path +- `detail: string | undefined` — Provider-chosen display detail (e.g., branch name) +- `baseBranchProtected: boolean | undefined` — Whether the base branch is protected + +**`SessionStatus`** — Enum: `Untitled`, `InProgress`, `NeedsInput`, `Completed`, `Error` + +--- + +### `ISessionsProvider` — Provider Contract + +**File:** `src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts` + +A sessions provider encapsulates a compute environment. It owns workspace discovery, session creation, session listing, and picker contributions. One provider can serve multiple session types, and multiple provider instances can serve the same session type (e.g., one per remote agent host). + +#### Identity + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `string` | Unique provider instance ID (e.g., `'default-copilot'`, `'agenthost-hostA-copilot'`) | +| `label` | `string` | Display label | +| `icon` | `ThemeIcon` | Provider icon | +| `sessionTypes` | `readonly ISessionType[]` | Session types this provider supports | + +#### Workspace Discovery + +| Member | Description | +|--------|-------------| +| `browseActions: readonly ISessionsBrowseAction[]` | Actions shown in the workspace picker (e.g., "Browse Folders...", "Browse Repositories...") | +| `resolveWorkspace(repositoryUri: URI): ISessionWorkspace` | Resolve a URI to a session workspace with label and icon | + +#### Session Listing + +| Member | Description | +|--------|-------------| +| `getSessions(): ISessionData[]` | Returns all sessions owned by this provider | +| `onDidChangeSessions: Event` | Fires when sessions are added, removed, or changed | + +#### Session Lifecycle + +| Method | Description | +|--------|-------------| +| `createNewSession(workspace)` | Create a new session for a given workspace | +| `setSessionType(sessionId, type)` | Change the session type | +| `getSessionTypes(session)` | Get available session types for a session | +| `renameSession(sessionId, title)` | Rename a session | +| `setModel(sessionId, modelId)` | Set the model | +| `archiveSession(sessionId)` | Archive a session | +| `unarchiveSession(sessionId)` | Unarchive a session | +| `deleteSession(sessionId)` | Delete a session | +| `setRead(sessionId, read)` | Mark read/unread | + +#### Send + +| Method | Description | +|--------|-------------| +| `sendRequest(sessionId, options)` | Send the initial request for a new session; returns the created `ISessionData` | + +#### Supporting Types + +**`ISessionType`** — A platform-level session type identifying an agent backend: +- `id: string` — Unique identifier (e.g., `'background'`, `'cloud'`) +- `label: string` — Display label +- `icon: ThemeIcon` — Icon +- `requiresWorkspaceTrust?: boolean` — Whether workspace trust is required + +**`ISessionsBrowseAction`** — A browse action shown in the workspace picker: +- `label`, `icon`, `providerId` +- `execute(): Promise` — Opens the browse dialog + +**`ISessionsChangeEvent`** — Change event: +- `added: readonly ISessionData[]` +- `removed: readonly ISessionData[]` +- `changed: readonly ISessionData[]` + +**`ISendRequestOptions`** — Send request options: +- `query: string` — Query text +- `attachedContext?: IChatRequestVariableEntry[]` — Optional attached context entries + +--- + +### `ISessionsProvidersService` — Central Registry & Aggregator + +**File:** `src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts` + +Central service that aggregates sessions across all registered providers. Owns the provider registry, unified session list, and routes session actions to the correct provider. + +#### Provider Registry + +| Member | Description | +|--------|-------------| +| `registerProvider(provider): IDisposable` | Register a provider; returns disposable to unregister | +| `getProviders(): ISessionsProvider[]` | Get all registered providers | +| `onDidChangeProviders: Event` | Fires when providers are added or removed | + +#### Session Types + +| Member | Description | +|--------|-------------| +| `getSessionTypesForProvider(providerId)` | Get session types from a specific provider | +| `getSessionTypes(session)` | Get session types available for a session | + +#### Aggregated Sessions + +| Member | Description | +|--------|-------------| +| `getSessions(): ISessionData[]` | Get all sessions from all providers | +| `getSession(sessionId): ISessionData \| undefined` | Look up a session by its globally unique ID | +| `onDidChangeSessions: Event` | Fires when sessions change across any provider | + +#### Routed Actions + +Actions are automatically routed to the correct provider by extracting the provider ID from the session ID: + +- `archiveSession(sessionId)` +- `unarchiveSession(sessionId)` +- `deleteSession(sessionId)` +- `renameSession(sessionId, title)` +- `setRead(sessionId, read)` +- `resolveWorkspace(providerId, repositoryUri)` + +#### Session ID Format + +Session IDs use the format `${providerId}:${localId}`, where `providerId` identifies the owning provider and `localId` is a provider-scoped identifier (typically the session resource URI string). The separator `:` allows the registry to parse the provider ID for routing. + +--- + +### `ISessionsManagementService` — High-Level Orchestration + +**File:** `src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts` + +Coordinates active session tracking, provider selection, and user workflows. Sits above the providers service and adds UI-facing concerns. + +#### Key Responsibilities + +- **Active session tracking** — `activeSession: IObservable` tracks the currently selected session +- **Provider selection** — `activeProviderId: IObservable` auto-selects when one provider exists, persists to storage +- **Context keys** — Manages `isNewChatSession`, `activeSessionProviderId`, `activeSessionType`, `isActiveSessionBackgroundProvider` +- **Session creation** — `createNewSession(providerId, workspace)` delegates to the correct provider +- **Send orchestration** — `sendRequest(session, options)` sends through the provider and manages state transitions +- **GitHub context** — `getGitHubContext(session)` derives owner/repo/PR info from session metadata +- **File resolution** — `resolveSessionFileUri(sessionResource, relativePath)` resolves file paths within session worktrees + +--- + +## Provider Implementations + +### `CopilotChatSessionsProvider` — Default Copilot Provider + +**File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` + +The default sessions provider, registered with ID `'default-copilot'`. Wraps the existing agent session infrastructure into the extensible provider model. Supports two session types: **Copilot CLI** (local) and **Copilot Cloud** (remote). + +#### Registration + +Registered via `DefaultSessionsProviderContribution` workbench contribution at `WorkbenchPhase.AfterRestored`: + +``` +src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +``` + +```typescript +class DefaultSessionsProviderContribution extends Disposable { + constructor(instantiationService, sessionsProvidersService) { + const provider = instantiationService.createInstance(CopilotChatSessionsProvider); + sessionsProvidersService.registerProvider(provider); + } +} +``` + +#### Identity + +| Property | Value | +|----------|-------| +| `id` | `'default-copilot'` | +| `label` | `'Copilot Chat'` | +| `icon` | `Codicon.copilot` | +| `sessionTypes` | `[CopilotCLISessionType, CopilotCloudSessionType]` | + +#### Browse Actions + +- **"Browse Folders..."** — Opens a folder dialog; creates a workspace with a `file://` URI +- **"Browse Repositories..."** — Executes `github.copilot.chat.cloudSessions.openRepository`; creates a workspace with a `github-remote-file://` URI + +#### New Session Classes + +When `createNewSession(workspace)` is called, the provider creates one of two concrete `ISessionData` implementations based on the workspace URI scheme: + +**`CopilotCLISession`** — For local `file://` workspaces: +- Implements `ISessionData` plus provider-specific observable fields (`permissionLevel`, `branchObservable`, `isolationModeObservable`) +- Performs async git repository resolution during construction (sets `loading` to true until resolved) +- Configuration methods: `setIsolationMode()`, `setBranch()`, `setModelId()`, `setMode()`, `setPermissionLevel()`, `setModeById()` +- Tracks selected options via `Map` and syncs to `IChatSessionsService` +- Uses `IGitService` to open the repository and resolve branch information + +**`RemoteNewSession`** — For cloud `github-remote-file://` workspaces: +- Implements `ISessionData` +- Manages dynamic option groups from `IChatSessionsService.getOptionGroupsForSessionType()` with `when` clause visibility +- No-ops for isolation/branch/client mode (cloud-managed) +- Provides `getModelOptionGroup()`, `getOtherOptionGroups()` for UI to render provider-specific pickers +- Watches context key changes to dynamically show/hide option groups + +#### `AgentSessionAdapter` — Wrapping Existing Sessions + +Adapts an existing `IAgentSession` from the chat layer into the `ISessionData` facade: +- Constructs with initial values from the agent session's metadata and timing +- `update(session)` performs a batched observable transaction to update all reactive properties +- Extracts workspace info, changes, description, and PR URI from session metadata +- Maps `ChatSessionStatus` → `SessionStatus` +- Handles both CLI and Cloud session metadata formats for repository resolution + +#### Session Cache & Change Events + +The provider maintains a `Map` cache keyed by resource URI: +- `_ensureSessionCache()` performs lazy initialization +- `_refreshSessionCache()` diffs current `IAgentSession` list against the cache, producing `added`, `removed`, and `changed` arrays +- Changed adapters are updated in-place via `adapter.update(session)` +- Change events are forwarded through `onDidChangeSessions` + +#### Send Flow + +1. Validate the session is a current new session (`CopilotCLISession` or `RemoteNewSession`) +2. Resolve mode, permission level, and send options from session configuration +3. Get or create a chat session via `IChatSessionsService` +4. Open the chat widget via `IChatWidgetService.openSession()` +5. Load the session model and apply selected model, mode, and options +6. Send the request via `IChatService.sendRequest()` +7. Wait for a new `IAgentSession` to appear in `IAgentSessionsService.model.sessions` +8. Wrap the new agent session as `AgentSessionAdapter` and return it +9. Clear the current new session reference + +--- + +### `RemoteAgentHostSessionsProvider` — Remote Agent Host Provider + +**File:** `src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts` + +A sessions provider for a single agent on a remote agent host connection. One instance is created per agent discovered on each connection. + +#### Registration + +Registered dynamically by `RemoteAgentHostContribution`: + +``` +src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +``` + +- Monitors `IRemoteAgentHostService.onDidChangeConnections` +- Creates one `RemoteAgentHostSessionsProvider` per agent per connection +- Registers via `sessionsProvidersService.registerProvider(sessionsProvider)` into a per-agent `DisposableStore` +- Disposes providers when connections are removed + +#### Identity + +| Property | Format | +|----------|--------| +| `id` | `'agenthost-${sanitizedAuthority}-${agentProvider}'` | +| `label` | Connection name or `'${agentProvider} (${address})'` | +| `icon` | `Codicon.remote` | +| `sessionTypes` | `[CopilotCLISessionType]` (reuses the platform type) | + +#### Browse Actions + +- **"Browse Remote Folders..."** — Opens a file dialog scoped to the agent host filesystem (`agent-host://` scheme) + +#### New Session Behavior + +`createNewSession(workspace)` creates a minimal `ISessionData` object literal (not a class instance) with: +- All observable fields initialized via `observableValue()` +- Status set to `SessionStatus.Untitled` +- Workspace label derived from the URI path + +#### Stubbed Operations + +Most session actions are no-ops because session lifecycle is managed by the existing `AgentHostSessionHandler` and `AgentHostSessionListController`, which are registered separately by the contribution: +- `archiveSession` / `unarchiveSession` / `deleteSession` / `renameSession` / `setRead` — no-op +- `sendRequest` — throws (handled by the session handler) +- `getSessions()` — returns empty array (managed by the list controller) + +--- + +## Data Flow: Session Lifecycle + +### Creating a New Session + +``` +1. User picks a workspace in the workspace picker + → SessionsManagementService.createNewSession(providerId, workspace) + → Looks up provider by ID in SessionsProvidersService + → Calls provider.createNewSession(workspace) + → Provider creates CopilotCLISession (file://) or RemoteNewSession (github-remote-file://) + → Returns ISessionData, sets as activeSession observable + +2. User configures session (model, isolation mode, branch) + → Modifies observable fields on the new session object + → Selections synced to IChatSessionsService via setOption() + +3. User types a message and sends + → SessionsManagementService.sendRequest(session, {query, attachedContext}) + → Delegates to provider.sendRequest(sessionId, options) + → Provider opens chat widget, applies config, sends through IChatService + → Waits for IAgentSession to appear in the model + → Wraps as AgentSessionAdapter, caches it + → Returns new ISessionData + → isNewChatSession context → false +``` + +### Session Change Propagation + +``` +Agent session state changes (turn complete, status update, etc.) + → AgentSessionsService.model.onDidChangeSessions + → CopilotChatSessionsProvider._refreshSessionCache() + - Diffs current IAgentSession list vs cache + - Updates existing AgentSessionAdapter observables + - Creates/removes entries as needed + - Fires onDidChangeSessions { added, removed, changed } + → SessionsProvidersService forwards the event + → SessionsManagementService forwards and updates active session + → UI re-renders via observable subscriptions +``` + +### Session ID Routing + +When the management service or UI invokes an action (e.g., `archiveSession`): + +``` +sessionId = "default-copilot:background:///untitled-abc123" + ├──────────────┤ ├──────────────────────────────┤ + provider ID local ID (resource URI string) + +SessionsProvidersService._resolveProvider(sessionId) + → Splits at first ':' + → Looks up provider 'default-copilot' in the registry + → Delegates to provider.archiveSession(sessionId) +``` + +--- + +## Context Keys + +| Context Key | Type | Description | +|-------------|------|-------------| +| `isNewChatSession` | `boolean` | `true` when viewing the new-session form (no established session selected) | +| `activeSessionProviderId` | `string` | Provider ID of the active session (e.g., `'default-copilot'`) | +| `activeSessionType` | `string` | Session type of the active session (e.g., `'background'`, `'cloud'`) | +| `isActiveSessionBackgroundProvider` | `boolean` | Whether the active session uses the background agent provider | + +--- + +## Adding a New Provider + +To add a new sessions provider: + +1. **Implement `ISessionsProvider`** with a unique `id`, supported `sessionTypes`, and `browseActions` +2. **Create session data classes** implementing `ISessionData` with observable properties for the new session type +3. **Register via a workbench contribution** at `WorkbenchPhase.AfterRestored`: + ```typescript + class MyProviderContribution extends Disposable implements IWorkbenchContribution { + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + const provider = this._register(instantiationService.createInstance(MyProvider)); + this._register(sessionsProvidersService.registerProvider(provider)); + } + } + registerWorkbenchContribution2(MyProviderContribution.ID, MyProviderContribution, WorkbenchPhase.AfterRestored); + ``` +4. Session IDs must use the format `${providerId}:${localId}` so the registry can route actions correctly +5. Fire `onDidChangeSessions` when sessions are added, removed, or updated +6. The provider's `browseActions` will automatically appear in the workspace picker +7. The provider's `sessionTypes` will be available for session type selection diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 39f7697ed7280..74e7b88061d78 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -9,18 +9,16 @@ import { KeyCode, KeyMod } from '../../base/common/keyCodes.js'; import { localize, localize2 } from '../../nls.js'; import { Categories } from '../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuRegistry, registerAction2 } from '../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { Menus } from './menus.js'; import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; -import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; +import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; // Register Icons -const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); -const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); -const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); -const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -32,13 +30,7 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: panelLeftOffIcon, - toggled: { - condition: SideBarVisibleContext, - icon: panelLeftIcon, - title: localize('primary sidebar', "Primary Side Bar"), - mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), }, @@ -49,12 +41,6 @@ class ToggleSidebarVisibilityAction extends Action2 { primary: KeyMod.CtrlCmd | KeyCode.KeyB }, menu: [ - { - id: Menus.TitleBarLeft, - group: 'navigation', - order: 0, - when: IsAuxiliaryWindowContext.toNegated() - }, { id: Menus.TitleBarContext, group: 'navigation', @@ -88,13 +74,7 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { super({ id: ToggleSecondarySidebarVisibilityAction.ID, title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), - icon: panelRightOffIcon, - toggled: { - condition: AuxiliaryBarVisibleContext, - icon: panelRightIcon, - title: localize('secondary sidebar', "Secondary Side Bar"), - mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), }, @@ -102,10 +82,10 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRight, + id: Menus.AuxiliaryBarTitle, group: 'navigation', - order: 10, - when: IsAuxiliaryWindowContext.toNegated() + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), AuxiliaryBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -163,7 +143,7 @@ registerAction2(ToggleSecondarySidebarVisibilityAction); registerAction2(TogglePanelVisibilityAction); // Floating window controls: always-on-top -MenuRegistry.appendMenuItem(Menus.TitleBarRight, { +MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { command: { id: 'workbench.action.toggleWindowAlwaysOnTop', title: localize('toggleWindowAlwaysOnTop', "Toggle Always on Top"), diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 1ba1c29e04b1b..555bd36ada05b 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -15,42 +15,225 @@ * * Margin values (must match the constants in the Part classes): * Sidebar: no card (flush, spans full height) - * Auxiliary bar: top=8, bottom=8, right=8 - * Panel: bottom=8, left=8, right=8 + * Auxiliary bar: top=16, bottom=18, right=16 + * Panel: bottom=18, left=16, right=16 */ .agent-sessions-workbench .part.sidebar { - background: var(--vscode-sideBar-background); - border-right: 1px solid var(--vscode-sideBar-border, transparent); + background: var(--vscode-sessionsSidebar-background); +} + +.agent-sessions-workbench .part.chatbar { + margin: 0 12px 2px 12px; + background: var(--part-background); + border: 1px solid var(--part-border-color, transparent); + border-radius: 8px; + box-sizing: border-box; +} + +.agent-sessions-workbench:not(.nosidebar) .part.chatbar { + margin-left: 0; } .agent-sessions-workbench .part.auxiliarybar { - margin: 8px 8px 8px 0; + margin: 0 12px 2px 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + box-sizing: border-box; } .agent-sessions-workbench .part.panel { - margin: 0 8px 8px 8px; + margin: 0 12px 14px 12px; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + box-sizing: border-box; } -/* Grid background matches the chat bar / sidebar background */ +.agent-sessions-workbench:not(.nosidebar) .part.panel { + margin-left: 0; +} + +/* Grid background matches the sessions sidebar background */ .agent-sessions-workbench > .monaco-grid-view { - background-color: var(--vscode-sideBar-background); + background-color: var(--vscode-sessionsSidebar-background); +} + +/* ---- Chat Layout ---- */ + +/* Remove max-width from the session container so the scrollbar extends full width */ +.agent-sessions-workbench .interactive-session { + max-width: none; +} + +/* Constrain content items to the same max-width, centered */ +.agent-sessions-workbench .interactive-session .interactive-item-container { + max-width: 950px; + margin: 0 auto; + padding-left: 16px; + padding-right: 16px; + box-sizing: border-box; +} + +.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { + max-width: 950px; + margin: 0 auto; + padding-left: 16px; + padding-right: 16px; + box-sizing: border-box; +} + +/* ---- Chat Output ---- */ + +/* Top fade overlay: dims content scrolled near the top edge of the chat bar card. + * Use the Monaco scroll shadow element, which is only shown when scrollTop > 0. */ +.agent-sessions-workbench .part.chatbar .monaco-scrollable-element > .shadow.top { + height: 16px; + background: linear-gradient(to bottom, var(--part-background), transparent); + box-shadow: none; } /* ---- Chat Input ---- */ .agent-sessions-workbench .interactive-session .chat-input-container { - border-radius: 8px !important; + border-radius: var(--vscode-cornerRadius-large) !important; } .agent-sessions-workbench .interactive-session .interactive-input-part { - margin: 0 8px !important; + width: 100%; + max-width: 950px; + margin: 0 auto !important; display: inherit !important; - padding: 4px 0 8px 0px !important; + /* Align with panel (terminal) card margin */ + padding: 4px 16px 16px 16px !important; + box-sizing: border-box; +} + +/* ---- Modal Editor Block ---- */ + +.agent-sessions-workbench .monaco-modal-editor-block { + background: rgba(0, 0, 0, 0.5); +} + + +/* ---- Customization Empty State ---- */ + +/* Icon + title side by side in a row, description underneath */ +.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header > .empty-state-icon { + font-size: 16px; + margin-bottom: 0; + flex-shrink: 0; +} + +/* MCP / Plugin empty state: icon + title side by side */ +.agent-sessions-workbench .mcp-empty-state .empty-state-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.agent-sessions-workbench .mcp-empty-state .empty-state-header > .empty-icon { + font-size: 16px; + margin-bottom: 0; + flex-shrink: 0; +} + +/* ---- Part Appear Transitions ---- */ + +/* + * Subtle appear animation when parts transition from display:none → visible + * (via split-view-view .visible class). + * + * Animated properties: opacity, margin, border-color, background. + * Opacity transiently creates a stacking context while it animates from 0 to 1 + * over 250ms — once settled at opacity: 1, no additional stacking context is + * introduced by this animation. Margin shifts are purely visual within the + * grid-allocated space. + */ + +.agent-sessions-workbench .part.sidebar, +.agent-sessions-workbench .part.auxiliarybar, +.agent-sessions-workbench .part.panel, +.agent-sessions-workbench .part.chatbar { + transition: + opacity 250ms ease-out, + margin-top 250ms ease-out, + margin-right 250ms ease-out, + margin-bottom 250ms ease-out, + border-color 250ms ease-out, + background 250ms ease-out; +} + +/* Sidebar & auxiliary bar also transition margin-left */ +.agent-sessions-workbench .part.sidebar, +.agent-sessions-workbench .part.auxiliarybar { + transition: + opacity 250ms ease-out, + margin 250ms ease-out, + border-color 250ms ease-out, + background 250ms ease-out; +} + +@starting-style { + /* Shared starting values */ + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + opacity: 0; + border-color: transparent; + } + + /* Card parts: blend from surrounding background */ + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + background: color-mix(in srgb, var(--part-background) 60%, var(--vscode-sideBar-background)); + } + + /* Per-part margin shifts — each part settles into its resting margin */ + /* Sidebar (left): slides in from 6px left → margin: 0 */ + .agent-sessions-workbench .part.sidebar { + margin-left: -6px; + } + + /* Panel (bottom): slides down from 6px above → margin: 0 16px 18px 16px */ + .agent-sessions-workbench .part.panel { + margin: 6px 16px 18px 16px; + } + + /* Auxiliary bar (right): slides in from 6px right → margin: 0 16px 2px 0 */ + .agent-sessions-workbench .part.auxiliarybar { + margin: 0 16px 2px 6px; + } + + /* Chat bar (center-bottom): slides up from 6px below → margin: 0 16px 2px 16px */ + .agent-sessions-workbench .part.chatbar { + margin: 6px 16px 2px 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + transition: none; + } +} + +/* ---- Widget Customizations ---- */ + +/* Badge */ +.agent-sessions-workbench .badge > .badge-content { + border-radius: 4px !important; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index ed06a0221d951..600ab4d3cbda4 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -13,11 +13,10 @@ export const Menus = { CommandCenter: new MenuId('SessionsCommandCenter'), CommandCenterCenter: new MenuId('SessionsCommandCenterCenter'), TitleBarContext: new MenuId('SessionsTitleBarContext'), - TitleBarControlMenu: new MenuId('SessionsTitleBarControlMenu'), - TitleBarLeft: new MenuId('SessionsTitleBarLeft'), - TitleBarCenter: new MenuId('SessionsTitleBarCenter'), - TitleBarRight: new MenuId('SessionsTitleBarRight'), - OpenSubMenu: new MenuId('SessionsOpenSubMenu'), + TitleBarLeftLayout: new MenuId('SessionsTitleBarLeftLayout'), + TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), + TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), + TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), PanelTitle: new MenuId('SessionsPanelTitle'), SidebarTitle: new MenuId('SessionsSidebarTitle'), AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), @@ -25,4 +24,10 @@ export const Menus = { SidebarFooter: new MenuId('SessionsSidebarFooter'), SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), + + // New session picker menus — providers contribute actions into these + // scoped by context keys (sessionsProviderId, sessionType, etc.) + NewSessionRepositoryConfig: new MenuId('NewSessions.RepositoryConfigMenu'), + NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), + NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), } as const; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index c7dbddc3e33c3..97dad470f2064 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import '../../../workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; +import './media/auxiliaryBarPart.css'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; @@ -12,8 +13,9 @@ import { INotificationService } from '../../../platform/notification/common/noti import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from '../../../workbench/common/contextkeys.js'; -import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsAuxiliaryBarBackground } from '../../common/theme.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -46,9 +48,9 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; /** Visual margin values for the card-like appearance */ - static readonly MARGIN_TOP = 8; - static readonly MARGIN_BOTTOM = 8; - static readonly MARGIN_RIGHT = 8; + static readonly MARGIN_TOP = 12; + static readonly MARGIN_BOTTOM = 2; + static readonly MARGIN_RIGHT = 12; // Action ID for run script - defined here to avoid layering issues private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; @@ -81,7 +83,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 300); + return Math.max(width, 380); } readonly priority = LayoutPriority.Low; @@ -105,7 +107,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { { hasTitle: true, trailingSeparator: false, - borderWidth: () => (this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder)) ? 1 : 0, + borderWidth: () => 0, }, AuxiliaryBarPart.activeViewSettingsKey, ActiveAuxiliaryContext.bindTo(contextKeyService), @@ -140,9 +142,9 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { const container = assertReturnsDefined(this.getContainer()); // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(SIDE_BAR_BACKGROUND) || ''); - container.style.setProperty('--part-border-color', this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || 'transparent'); - container.style.backgroundColor = 'transparent'; + container.style.setProperty('--part-background', this.getColor(sessionsAuxiliaryBarBackground) || ''); + container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = this.getColor(sessionsAuxiliaryBarBackground) || ''; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; // Clear borders - the card appearance uses border-radius instead @@ -172,8 +174,8 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { iconSize: 16, get overflowActionSize() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? 40 : 30; }, colors: theme => ({ - activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), - inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBackgroundColor: theme.getColor(sessionsAuxiliaryBarBackground), + inactiveBackgroundColor: theme.getColor(sessionsAuxiliaryBarBackground), get activeBorderBottomColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER); }, get activeForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND); }, get inactiveForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND); }, @@ -260,10 +262,11 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return; } - // Layout content with reduced dimensions to account for visual margins + // Layout content with reduced dimensions to account for visual margins and border + const borderTotal = 2; // 1px border on each side super.layout( - width - AuxiliaryBarPart.MARGIN_RIGHT, - height - AuxiliaryBarPart.MARGIN_TOP - AuxiliaryBarPart.MARGIN_BOTTOM, + width - AuxiliaryBarPart.MARGIN_RIGHT - borderTotal, + height - AuxiliaryBarPart.MARGIN_TOP - AuxiliaryBarPart.MARGIN_BOTTOM - borderTotal, top, left ); diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 9a74bb7021bd0..35ae014de3df7 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -11,7 +11,9 @@ import { IKeybindingService } from '../../../platform/keybinding/common/keybindi import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsChatBarBackground } from '../../common/theme.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -19,6 +21,7 @@ import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; import { IMenuService } from '../../../platform/actions/common/actions.js'; @@ -34,29 +37,22 @@ export class ChatBarPart extends AbstractPaneCompositePart { static readonly placeholderViewContainersKey = 'workbench.chatbar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; - // Use the side bar dimensions - override readonly minimumWidth: number = 170; + override readonly minimumWidth: number = 300; override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; - get preferredHeight(): number | undefined { - return this.layoutService.mainContainerDimension.height * 0.4; - } - - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_TOP = 12; + static readonly MARGIN_LEFT = 12; + static readonly MARGIN_RIGHT = 12; + static readonly MARGIN_BOTTOM = 2; - if (!activeComposite) { - return undefined; - } - - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } + /** Border width on the card (1px each side) */ + static readonly BORDER_WIDTH = 1; - return Math.max(width, 300); + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; } readonly priority = LayoutPriority.High; @@ -112,10 +108,32 @@ export class ChatBarPart extends AbstractPaneCompositePart { super.updateStyles(); const container = assertReturnsDefined(this.getContainer()); - container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + + // Store background and border as CSS variables for the card styling on .part + container.style.setProperty('--part-background', this.getColor(sessionsChatBarBackground) || ''); + container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = this.getColor(sessionsChatBarBackground) || ''; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; } + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.CHATBAR_PART)) { + return; + } + + // Layout content with reduced dimensions to account for visual margins and border + const borderTotal = ChatBarPart.BORDER_WIDTH * 2; + const marginLeft = this.layoutService.isVisible(Parts.SIDEBAR_PART) ? 0 : ChatBarPart.MARGIN_LEFT; + super.layout( + width - marginLeft - ChatBarPart.MARGIN_RIGHT - borderTotal, + height - ChatBarPart.MARGIN_TOP - ChatBarPart.MARGIN_BOTTOM - borderTotal, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + Part.prototype.layout.call(this, width, height, top, left); + } + protected getCompositeBarOptions(): IPaneCompositeBarOptions { return { partContainerClass: 'chatbar', @@ -133,8 +151,8 @@ export class ChatBarPart extends AbstractPaneCompositePart { iconSize: 16, overflowActionSize: 30, colors: theme => ({ - activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), - inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBackgroundColor: theme.getColor(sessionsChatBarBackground), + inactiveBackgroundColor: theme.getColor(sessionsChatBarBackground), activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), diff --git a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css new file mode 100644 index 0000000000000..d3802d00b107d --- /dev/null +++ b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ===== Modern action label styling for sessions auxiliary bar ===== */ + +/* Base label: lowercase text + heavier weight + pill padding */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { + text-transform: capitalize; + font-weight: 500; + border-radius: 4px; + padding: 0px 8px; + font-size: 12px; + line-height: 22px; +} + +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { + padding-left: 0; + padding-right: 0; +} + +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge { + margin-left: -4px; + padding-right: 6px; +} + +.agent-sessions-workbench .part.auxiliarybar > .title { + padding-left: 4px; + padding-right: 2px; + background-color: var(--vscode-sessionsAuxiliaryBar-background); +} + +/* Hide the underline indicator entirely */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before { + display: none !important; +} + +/* Active/checked state: background container instead of underline */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { + background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)) !important; + border-radius: 4px; +} + +/* Override base workbench monaco editor background in auxiliary bar content */ +.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor, +.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor .margin, +.agent-sessions-workbench .part.auxiliarybar > .content .monaco-editor .monaco-editor-background { + background-color: var(--vscode-sessionsAuxiliaryBar-background); +} diff --git a/src/vs/sessions/browser/parts/media/chatBarPart.css b/src/vs/sessions/browser/parts/media/chatBarPart.css index 4db26e2e5b032..94dfd719e5000 100644 --- a/src/vs/sessions/browser/parts/media/chatBarPart.css +++ b/src/vs/sessions/browser/parts/media/chatBarPart.css @@ -14,6 +14,10 @@ background-color: var(--vscode-sideBar-background); } +.monaco-workbench .part.chatbar > .content > .monaco-progress-container { + top: 0; +} + .monaco-workbench .part.chatbar .title-actions .actions-container { justify-content: flex-end; } diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 92e2987bb3282..7156bcb1d9101 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -7,3 +7,39 @@ .monaco-workbench .part.panel.bottom .composite.title { border-top-width: 0; } + +/* ===== Modern action label styling for sessions panel ===== */ + +/* Hide the underline indicator entirely */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before { + display: none !important; +} + +/* Make icon action items 24px tall */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { + height: 24px; +} + +.agent-sessions-workbench .part.panel > .title { + padding-left: 6px; + padding-right: 6px; + background-color: var(--vscode-sessionsPanel-background); +} + +/* Active/checked state: background container instead of underline */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { + background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)) !important; + border-radius: 4px; +} + +/* Override base workbench panel content background for terminal */ +.agent-sessions-workbench .part.panel > .content .monaco-editor, +.agent-sessions-workbench .part.panel > .content .monaco-editor .margin, +.agent-sessions-workbench .part.panel > .content .monaco-editor .monaco-editor-background { + background-color: var(--vscode-sessionsPanel-background); +} + +/* Terminal body background */ +.agent-sessions-workbench .part.panel .terminal-wrapper { + background-color: var(--vscode-sessionsPanel-background); +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 0162bcb26d036..bc92b2b424e1d 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -24,20 +24,37 @@ } /* Interactive elements in the title area must not be draggable */ -.agent-sessions-workbench .part.sidebar > .composite.title .action-item { +.agent-sessions-workbench .part.sidebar > .composite.title .action-item, +.agent-sessions-workbench .part.sidebar > .composite.title > .session-status-toggle { -webkit-app-region: no-drag; } /* Sidebar Footer Container */ -.monaco-workbench .part.sidebar > .sidebar-footer { +.agent-sessions-workbench .part.sidebar > .sidebar-footer { display: flex; align-items: stretch; gap: 4px; - padding: 6px; - border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); + padding: 6px 0; + border-top: 1px solid var(--vscode-panel-border, transparent); + margin: 0 12px 12px 12px; flex-shrink: 0; } +/* Inset pane section header borders — hide inline border-top, replace with inset pseudo-element */ +.agent-sessions-workbench .part.sidebar .pane > .pane-header { + border-top-color: transparent !important; +} + +.agent-sessions-workbench .part.sidebar .split-view-view:not(:first-of-type) > .pane > .pane-header::before { + content: ''; + position: absolute; + top: 0; + left: 12px; + right: 0; + height: 1px; + background-color: var(--vscode-panel-border, transparent); +} + /* Make the toolbar fill the footer width and stack actions vertically */ .monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, .monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, @@ -56,3 +73,38 @@ max-width: 100%; cursor: default; } + +/* Session status toggle — standalone button in sidebar title area */ +.agent-sessions-workbench .part.sidebar .session-status-toggle { + display: flex; + align-items: center; + align-self: center; + gap: 3px; + height: 22px; + padding: 0 4px; + margin-right: 4px; + border: none; + border-radius: 4px; + cursor: pointer; + color: inherit; + font: inherit; + background: var(--vscode-toolbar-activeBackground); + outline: none; + position: relative; + z-index: 1; +} + +.agent-sessions-workbench .part.sidebar .session-status-toggle:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .part.sidebar .session-status-toggle .codicon { + font-size: 16px; +} + +.agent-sessions-workbench .part.sidebar .session-status-toggle-badge { + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: inherit; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 22273655103c7..6c22ca862d379 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -3,8 +3,87 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { + display: flex; + height: 100%; + align-items: center; + order: 0; + flex-grow: 0; + flex-shrink: 0; + width: auto; + justify-content: flex-start; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { + order: 1; + width: auto; + flex-grow: 0; + flex-shrink: 1; + min-width: 0px; + margin: 0; + justify-content: flex-start; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { + width: fit-content; + flex-grow: 0; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center { + flex: 1; + max-width: none; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center .window-title { + margin: unset; +} + +.agent-sessions-workbench.monaco-workbench.mac .part.titlebar > .sessions-titlebar-container > .titlebar-right { + order: 2; + width: fit-content; + flex-grow: 0; + justify-content: flex-end; + margin-right: 12px; +} + +/* Session Title Actions Container (before right toolbar) */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { + display: none; + flex-shrink: 0; + -webkit-app-region: no-drag; + height: 100%; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { + display: flex; + align-items: center; +} + +/* Separator before right layout toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container:not(.has-no-actions)::before { + content: ''; + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-disabledForeground); +} + +/* Toggled action buttons in session actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item:not(.disabled) .codicon { + color: var(--vscode-icon-foreground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item { + display: flex; +} + /* Left Tool Bar Container */ -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { display: none; padding-left: 8px; flex-grow: 0; @@ -17,28 +96,30 @@ order: 2; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { display: flex; justify-content: center; align-items: center; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .codicon { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .codicon { color: inherit; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { display: flex; } -/* TODO: Hack to avoid flicker when sidebar becomes visible. - * The contribution swaps the menu item synchronously, but the toolbar - * re-render is async, causing a brief flash. Hide the container via - * CSS when sidebar is visible (nosidebar class is removed synchronously). */ -.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { +/* Hide the entire titlebar left when the sidebar is visible */ +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left { display: none !important; } +/* Remove the titlebar shadow in agent sessions */ +.agent-sessions-workbench.monaco-workbench .part.titlebar { + box-shadow: none; +} + /* macOS native: the spacer uses window-controls-container but should not block dragging */ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 867760bd11228..cc1f700ba018e 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -14,8 +14,9 @@ import { IContextMenuService } from '../../../platform/contextview/browser/conte import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { PANEL_BACKGROUND, PANEL_BORDER, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { PANEL_BORDER, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsPanelBackground } from '../../common/theme.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; @@ -68,9 +69,9 @@ export class PanelPart extends AbstractPaneCompositePart { static readonly activePanelSettingsKey = 'workbench.agentsession.panelpart.activepanelid'; /** Visual margin values for the card-like appearance */ - static readonly MARGIN_BOTTOM = 8; - static readonly MARGIN_LEFT = 8; - static readonly MARGIN_RIGHT = 8; + static readonly MARGIN_BOTTOM = 14; + static readonly MARGIN_LEFT = 12; + static readonly MARGIN_RIGHT = 12; constructor( @INotificationService notificationService: INotificationService, @@ -128,9 +129,9 @@ export class PanelPart extends AbstractPaneCompositePart { const container = assertReturnsDefined(this.getContainer()); // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(PANEL_BACKGROUND) || ''); + container.style.setProperty('--part-background', this.getColor(sessionsPanelBackground) || ''); container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); - container.style.backgroundColor = 'transparent'; + container.style.backgroundColor = this.getColor(sessionsPanelBackground) || ''; // Clear inline borders - the card appearance uses CSS border-radius instead container.style.borderTopColor = ''; @@ -156,8 +157,8 @@ export class PanelPart extends AbstractPaneCompositePart { compact: true, overflowActionSize: 44, colors: theme => ({ - activeBackgroundColor: theme.getColor(PANEL_BACKGROUND), - inactiveBackgroundColor: theme.getColor(PANEL_BACKGROUND), + activeBackgroundColor: theme.getColor(sessionsPanelBackground), + inactiveBackgroundColor: theme.getColor(sessionsPanelBackground), activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), @@ -175,10 +176,12 @@ export class PanelPart extends AbstractPaneCompositePart { return; } - // Layout content with reduced dimensions to account for visual margins + // Layout content with reduced dimensions to account for visual margins and border + const borderTotal = 2; // 1px border on each side + const marginLeft = this.layoutService.isVisible(Parts.SIDEBAR_PART) ? 0 : PanelPart.MARGIN_LEFT; super.layout( - width - PanelPart.MARGIN_LEFT - PanelPart.MARGIN_RIGHT, - height - PanelPart.MARGIN_BOTTOM, + width - marginLeft - PanelPart.MARGIN_RIGHT - borderTotal, + height - PanelPart.MARGIN_BOTTOM - borderTotal, top, left ); diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index 5f8cce31cf807..cca74c97a11f2 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -12,9 +12,8 @@ import { IContextMenuService } from '../../../platform/contextview/browser/conte import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND, SIDE_BAR_DRAG_AND_DROP_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; -import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; -import { sessionsSidebarBorder, sessionsSidebarHeaderBackground, sessionsSidebarHeaderForeground } from '../../common/theme.js'; +import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND, SIDE_BAR_DRAG_AND_DROP_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; +import { sessionsSidebarBackground, sessionsSidebarHeaderBackground, sessionsSidebarHeaderForeground } from '../../common/theme.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { AnchorAlignment } from '../../../base/browser/ui/contextview/contextview.js'; @@ -33,11 +32,19 @@ import { Separator } from '../../../base/common/actions.js'; import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { Menus } from '../menus.js'; -import { $, append, getWindowId, prepend } from '../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventType, getWindowId, prepend } from '../../../base/browser/dom.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; import { isMacintosh, isNative } from '../../../base/common/platform.js'; import { isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; import { mainWindow } from '../../../base/browser/window.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { hasNativeTitlebar, getTitleBarStyle } from '../../../platform/window/common/window.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { IAgentSessionsService } from '../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { countUnreadSessions } from '../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { localize } from '../../../nls.js'; /** * Sidebar part specifically for agent sessions workbench. @@ -57,6 +64,8 @@ export class SidebarPart extends AbstractPaneCompositePart { private static readonly FOOTER_ITEM_HEIGHT = 26; private static readonly FOOTER_ITEM_GAP = 4; private static readonly FOOTER_VERTICAL_PADDING = 6; + private static readonly FOOTER_BOTTOM_MARGIN = 12; + private static readonly FOOTER_BORDER_TOP = 1; private footerContainer: HTMLElement | undefined; private sideBarTitleArea: HTMLElement | undefined; @@ -103,10 +112,11 @@ export class SidebarPart extends AbstractPaneCompositePart { @IContextKeyService contextKeyService: IContextKeyService, @IExtensionService extensionService: IExtensionService, @IMenuService menuService: IMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super( Parts.SIDEBAR_PART, - { hasTitle: true, trailingSeparator: false, borderWidth: () => (this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder)) ? 1 : 0 }, + { hasTitle: true, trailingSeparator: false, borderWidth: () => 0 }, SidebarPart.activeViewletSettingsKey, ActiveViewletContext.bindTo(contextKeyService), SidebarFocusContext.bindTo(contextKeyService), @@ -117,7 +127,7 @@ export class SidebarPart extends AbstractPaneCompositePart { ViewContainerLocation.Sidebar, Extensions.Viewlets, Menus.SidebarTitle, - Menus.TitleBarLeft, + Menus.TitleBarLeftLayout, notificationService, storageService, contextMenuService, @@ -148,10 +158,15 @@ export class SidebarPart extends AbstractPaneCompositePart { prepend(titleArea, $('div.titlebar-drag-region')); } + // Session toggle widget (right side of title area) + if (titleArea) { + this.createSessionsToggle(titleArea); + } + // macOS native: the sidebar spans full height and the traffic lights // overlay the top-left corner. Add a fixed-width spacer inside the // title area to push content horizontally past the traffic lights. - if (titleArea && isMacintosh && isNative) { + if (titleArea && isMacintosh && isNative && !hasNativeTitlebar(this.configurationService, getTitleBarStyle(this.configurationService))) { const spacer = $('div.window-controls-container'); spacer.style.width = '70px'; spacer.style.height = '100%'; @@ -174,6 +189,49 @@ export class SidebarPart extends AbstractPaneCompositePart { return titleArea; } + /** + * Creates a standalone session toggle widget appended to the sidebar title area. + * Displays a tasklist icon with an optional unread badge. Clicking hides the sidebar. + */ + private createSessionsToggle(titleArea: HTMLElement): void { + const widgetDisposables = this._register(new DisposableStore()); + + const widget = append(titleArea, $('button.session-status-toggle')) as HTMLButtonElement; + widget.type = 'button'; + widget.tabIndex = 0; + widget.setAttribute('aria-label', localize('hideSidebar', "Hide Side Bar")); + append(widget, $(ThemeIcon.asCSSSelector(Codicon.tasklist))); + const badge = append(widget, $('span.session-status-toggle-badge')); + + // Toggle sidebar on click + widgetDisposables.add(addDisposableListener(widget, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.layoutService.setPartHidden(true, Parts.SIDEBAR_PART); + })); + + // Update badge on session changes (deferred to avoid service unavailability) + const updateBadge = (svc: IAgentSessionsService) => { + const unread = countUnreadSessions(svc.model.sessions); + badge.textContent = unread > 0 ? `${unread}` : ''; + badge.style.display = unread > 0 ? '' : 'none'; + widget.setAttribute('aria-label', unread > 0 + ? localize('hideSidebarUnread', "Hide Side Bar, {0} unread session(s)", unread) + : localize('hideSidebar', "Hide Side Bar")); + }; + + setTimeout(() => { + try { + const svc = this.instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); + updateBadge(svc); + widgetDisposables.add(svc.model.onDidChangeSessions(() => updateBadge(svc))); + } catch { + // Service not yet available + badge.style.display = 'none'; + } + }, 0); + } + private createFooter(parent: HTMLElement): void { const footer = append(parent, $('.sidebar-footer.sidebar-action-list')); this.footerContainer = footer; @@ -200,7 +258,9 @@ export class SidebarPart extends AbstractPaneCompositePart { return SidebarPart.FOOTER_VERTICAL_PADDING * 2 + (actionCount * SidebarPart.FOOTER_ITEM_HEIGHT) - + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP); + + ((actionCount - 1) * SidebarPart.FOOTER_ITEM_GAP) + + SidebarPart.FOOTER_BOTTOM_MARGIN + + SidebarPart.FOOTER_BORDER_TOP; } private updateFooterVisibility(): void { @@ -217,15 +277,14 @@ export class SidebarPart extends AbstractPaneCompositePart { const container = assertReturnsDefined(this.getContainer()); - container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + container.style.backgroundColor = this.getColor(sessionsSidebarBackground) || ''; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; container.style.outlineColor = this.getColor(SIDE_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; - // Right border to separate from the right section - const borderColor = this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder) || ''; - container.style.borderRightWidth = borderColor ? '1px' : ''; - container.style.borderRightStyle = borderColor ? 'solid' : ''; - container.style.borderRightColor = borderColor; + // No right border in sessions sidebar + container.style.borderRightWidth = ''; + container.style.borderRightStyle = ''; + container.style.borderRightColor = ''; // Title area uses sessions-specific header colors if (this.sideBarTitleArea) { @@ -292,8 +351,8 @@ export class SidebarPart extends AbstractPaneCompositePart { iconSize: 16, overflowActionSize: 30, colors: theme => ({ - activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), - inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBackgroundColor: theme.getColor(sessionsSidebarBackground), + inactiveBackgroundColor: theme.getColor(sessionsSidebarBackground), activeBorderBottomColor: theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER), activeForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND), inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND), diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 8eab246ab644e..c6895116d9c72 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -18,7 +18,7 @@ import { WORKBENCH_BACKGROUND } from '../../../workbench/common/theme.js'; import { chatBarTitleBackground, chatBarTitleForeground } from '../../common/theme.js'; import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; import { Color } from '../../../base/common/color.js'; -import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { EventType, EventHelper, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -81,6 +81,10 @@ export class TitlebarPart extends Part implements ITitlebarPart { private centerContent!: HTMLElement; private rightContent!: HTMLElement; + get leftContainer(): HTMLElement { return this.leftContent; } + get rightContainer(): HTMLElement { return this.rightContent; } + get rightWindowControlsContainer(): HTMLElement | undefined { return this.windowControlsContainer; } + private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; @@ -132,7 +136,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { protected override createContentArea(parent: HTMLElement): HTMLElement { this.element = parent; - this.rootContainer = append(parent, $('.titlebar-container.has-center')); + this.rootContainer = append(parent, $('.titlebar-container.sessions-titlebar-container.has-center')); // Draggable region prepend(this.rootContainer, $('div.titlebar-drag-region')); @@ -185,7 +189,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { // Left toolbar (driven by Menus.TitleBarLeft, rendered after window controls via CSS order) const leftToolbarContainer = append(this.leftContent, $('div.left-toolbar-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeft, { + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeftLayout, { contextMenu: Menus.TitleBarContext, telemetrySource: 'titlePart.left', hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -203,14 +207,24 @@ export class TitlebarPart extends Part implements ITitlebarPart { toolbarOptions: { primaryGroup: () => true }, })); - // Right toolbar (driven by Menus.TitleBarRight - includes account submenu) - const rightToolbarContainer = prepend(this.rightContent, $('div.action-toolbar-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRight, { + // Right toolbar (driven by Menus.TitleBarRightLayout - includes layout actions) + const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-right-layout-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, telemetrySource: 'titlePart.right', toolbarOptions: { primaryGroup: () => true }, })); + // Session title actions toolbar (before right toolbar) + const sessionActionsContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-session-actions-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, sessionActionsContainer, Menus.TitleBarSessionMenu, { + contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'titlePart.sessionActions', + toolbarOptions: { primaryGroup: () => true }, + })); + // Context menu on the titlebar this._register(addDisposableListener(this.rootContainer, EventType.CONTEXT_MENU, e => { EventHelper.stop(e); @@ -254,8 +268,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { }); } - private lastLayoutDimension: Dimension | undefined; - get hasZoomableElements(): boolean { return true; // sessions titlebar always has command center and toolbar actions } @@ -268,7 +280,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { } override layout(width: number, height: number): void { - this.lastLayoutDimension = new Dimension(width, height); this.updateLayout(); super.layoutContents(width, height); } @@ -281,24 +292,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { const zoomFactor = getZoomFactor(getWindow(this.element)); this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); - - this.updateCenterOffset(); - } - - private updateCenterOffset(): void { - if (!this.centerContent || !this.lastLayoutDimension) { - return; - } - - // Center the command center relative to the viewport. - // The titlebar only covers the right section (sidebar is to the left), - // so we shift the center content left by half the sidebar width - // using a negative margin. - const windowWidth = this.layoutService.mainContainerDimension.width; - const titlebarWidth = this.lastLayoutDimension.width; - const leftOffset = windowWidth - titlebarWidth; - this.centerContent.style.marginLeft = leftOffset > 0 ? `${-leftOffset / 2}px` : ''; - this.centerContent.style.marginRight = leftOffset > 0 ? `${leftOffset / 2}px` : ''; } focus(): void { diff --git a/src/vs/sessions/browser/web.factory.ts b/src/vs/sessions/browser/web.factory.ts new file mode 100644 index 0000000000000..e33129acea8a8 --- /dev/null +++ b/src/vs/sessions/browser/web.factory.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbench, IWorkbenchConstructionOptions } from '../../workbench/browser/web.api.js'; +import { SessionsBrowserMain } from './web.main.js'; +import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { mark } from '../../base/common/performance.js'; +import { DeferredPromise } from '../../base/common/async.js'; + +const workbenchPromise = new DeferredPromise(); + +/** + * Creates the Sessions workbench with the provided options in the provided container. + */ +export function create(domElement: HTMLElement, options: IWorkbenchConstructionOptions): IDisposable { + + mark('code/didLoadWorkbenchMain'); + + let instantiatedWorkbench: IWorkbench | undefined = undefined; + new SessionsBrowserMain(domElement, options).open().then(workbench => { + instantiatedWorkbench = workbench; + workbenchPromise.complete(workbench); + }); + + return toDisposable(() => { + if (instantiatedWorkbench) { + instantiatedWorkbench.shutdown(); + } else { + workbenchPromise.p.then(w => w.shutdown()); + } + }); +} diff --git a/src/vs/sessions/browser/web.main.ts b/src/vs/sessions/browser/web.main.ts new file mode 100644 index 0000000000000..0c57fb902f6c8 --- /dev/null +++ b/src/vs/sessions/browser/web.main.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { BrowserMain, IBrowserMainWorkbench } from '../../workbench/browser/web.main.js'; +import { Workbench as SessionsWorkbench } from './workbench.js'; + +export class SessionsBrowserMain extends BrowserMain { + + protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { + console.log('[Sessions Web] Creating Sessions workbench (not standard workbench)'); + return new SessionsWorkbench(domElement, undefined, serviceCollection, logService); + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 66e41f3a74276..3ebbfbf7da0e6 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -81,6 +81,7 @@ enum LayoutClasses { PANEL_HIDDEN = 'nopanel', AUXILIARYBAR_HIDDEN = 'noauxiliarybar', CHATBAR_HIDDEN = 'nochatbar', + STATUSBAR_HIDDEN = 'nostatusbar', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized' } @@ -398,15 +399,6 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Wrap up instantiationService.invokeFunction(accessor => { const lifecycleService = accessor.get(ILifecycleService); - - // TODO@Sandeep debt around cyclic dependencies - const configurationService = accessor.get(IConfigurationService); - // eslint-disable-next-line local/code-no-in-operator - if (configurationService && 'acquireInstantiationService' in configurationService) { - (configurationService as { acquireInstantiationService: (instantiationService: unknown) => void }).acquireInstantiationService(instantiationService); - } - - // Signal to lifecycle that services are set lifecycleService.phase = LifecyclePhase.Ready; }); @@ -599,6 +591,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); mark('code/didCreatePart/workbench.parts.editor'); + this.getPart(Parts.EDITOR_PART).layout(0, 0, 0, 0); // needed to make some view methods work + this.mainContainer.appendChild(editorPartContainer); } @@ -789,7 +783,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Default sizes const sideBarSize = 300; - const auxiliaryBarSize = 300; + const auxiliaryBarSize = 380; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; @@ -902,6 +896,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + LayoutClasses.STATUSBAR_HIDDEN, // sessions window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined ]); } @@ -1128,6 +1123,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.workbenchGrid.exitMaximizedView(); } + const panelHadFocus = !hidden || this.hasFocus(Parts.PANEL_PART); + this.partVisibility.panel = !hidden; this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, hidden); @@ -1140,15 +1137,24 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // If panel becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); + + // Focus the chat bar when hiding the panel if it had focus + if (panelHadFocus) { + this.focusPart(Parts.CHATBAR_PART); + } } - // If panel becomes visible, show last active panel or default - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id; - if (panelToOpen) { - this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + // If panel becomes visible, show last active panel or default and focus it + if (!hidden) { + if (!this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ?? + this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id; + if (panelToOpen) { + this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + } } + + this.focusPart(Parts.PANEL_PART); } } diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index d95d7411ac4cf..76d7136d14c5a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -6,12 +6,6 @@ import { localize } from '../../nls.js'; import { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; -//#region < --- Welcome --- > - -export const SessionsWelcomeCompleteContext = new RawContextKey('sessionsWelcomeComplete', false, localize('sessionsWelcomeComplete', "Whether the sessions welcome setup is complete")); - -//#endregion - //#region < --- Chat Bar --- > export const ActiveChatBarContext = new RawContextKey('activeChatBar', '', localize('activeChatBar', "The identifier of the active chat bar panel")); @@ -19,3 +13,9 @@ export const ChatBarFocusContext = new RawContextKey('chatBarFocus', fa export const ChatBarVisibleContext = new RawContextKey('chatBarVisible', false, localize('chatBarVisible', "Whether the chat bar is visible")); //#endregion + +//#region < --- Welcome --- > + +export const SessionsWelcomeVisibleContext = new RawContextKey('sessionsWelcomeVisible', false, localize('sessionsWelcomeVisible', "Whether the sessions welcome overlay is visible")); + +//#endregion diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index 4d17842818037..2217701d3473b 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -4,29 +4,44 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../nls.js'; -import { registerColor } from '../../platform/theme/common/colorUtils.js'; -import { contrastBorder } from '../../platform/theme/common/colorRegistry.js'; -import { Color } from '../../base/common/color.js'; -import { SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND } from '../../workbench/common/theme.js'; +import { getColorRegistry, registerColor, transparent } from '../../platform/theme/common/colorUtils.js'; +import { contrastBorder, iconForeground } from '../../platform/theme/common/colorRegistry.js'; +import { buttonBackground } from '../../platform/theme/common/colors/inputColors.js'; +import { editorBackground } from '../../platform/theme/common/colors/editorColors.js'; +import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND } from '../../workbench/common/theme.js'; // Sessions sidebar background color export const sessionsSidebarBackground = registerColor( 'sessionsSidebar.background', - SIDE_BAR_BACKGROUND, + editorBackground, localize('sessionsSidebar.background', 'Background color of the sidebar view in the agent sessions window.') ); -// Sessions sidebar border color -export const sessionsSidebarBorder = registerColor( - 'sessionsSidebar.border', - { dark: Color.fromHex('#808080').transparent(0.35), light: Color.fromHex('#808080').transparent(0.35), hcDark: contrastBorder, hcLight: contrastBorder }, - localize('sessionsSidebar.border', 'Border color of the sidebar in the agent sessions window.') +// Sessions auxiliary bar background color +export const sessionsAuxiliaryBarBackground = registerColor( + 'sessionsAuxiliaryBar.background', + SIDE_BAR_BACKGROUND, + localize('sessionsAuxiliaryBar.background', 'Background color of the auxiliary bar in the agent sessions window.') +); + +// Sessions panel background color +export const sessionsPanelBackground = registerColor( + 'sessionsPanel.background', + SIDE_BAR_BACKGROUND, + localize('sessionsPanel.background', 'Background color of the panel in the agent sessions window.') +); + +// Sessions chat bar background color +export const sessionsChatBarBackground = registerColor( + 'sessionsChatBar.background', + SIDE_BAR_BACKGROUND, + localize('sessionsChatBar.background', 'Background color of the chat bar in the agent sessions window.') ); // Sessions sidebar header colors export const sessionsSidebarHeaderBackground = registerColor( 'sessionsSidebarHeader.background', - SIDE_BAR_BACKGROUND, + sessionsSidebarBackground, localize('sessionsSidebarHeader.background', 'Background color of the sidebar header area in the agent sessions window.') ); @@ -39,7 +54,7 @@ export const sessionsSidebarHeaderForeground = registerColor( // Chat bar title colors export const chatBarTitleBackground = registerColor( 'chatBarTitle.background', - SIDE_BAR_BACKGROUND, + sessionsSidebarBackground, localize('chatBarTitle.background', 'Background color of the chat bar title area in the agent sessions window.') ); @@ -48,3 +63,28 @@ export const chatBarTitleForeground = registerColor( SIDE_BAR_FOREGROUND, localize('chatBarTitle.foreground', 'Foreground color of the chat bar title area in the agent sessions window.') ); + +// Agent feedback input widget border color +export const agentFeedbackInputWidgetBorder = registerColor( + 'agentFeedbackInputWidget.border', + { dark: transparent(iconForeground, 0.8), light: transparent(iconForeground, 0.8), hcDark: contrastBorder, hcLight: contrastBorder }, + localize('agentFeedbackInputWidget.border', 'Border color of the agent feedback input widget shown in the editor.') +); + +// Sessions update button colors +export const sessionsUpdateButtonDownloadingBackground = registerColor( + 'sessionsUpdateButton.downloadingBackground', + transparent(buttonBackground, 0.4), + localize('sessionsUpdateButton.downloadingBackground', 'Background color of the update button to show download progress in the agent sessions window.') +); + +export const sessionsUpdateButtonDownloadedBackground = registerColor( + 'sessionsUpdateButton.downloadedBackground', + transparent(buttonBackground, 0.7), + localize('sessionsUpdateButton.downloadedBackground', 'Background color of the update button when download is complete in the agent sessions window.') +); + +const colorRegistry = getColorRegistry(); + +// Override panel background to use editor background in sessions window +colorRegistry.updateDefaultColor(PANEL_BACKGROUND, editorBackground); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index cbff23edc4c94..c4d28d533855d 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -12,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -24,6 +24,13 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { URI } from '../../../../base/common/uri.js'; +import { UpdateHoverWidget } from './updateHoverWidget.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); @@ -81,20 +88,29 @@ MenuRegistry.appendMenuItem(AccountMenu, { // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); -class AccountWidget extends ActionViewItem { +export class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly updateHoverWidget: UpdateHoverWidget; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( action: IAction, options: IBaseActionViewItemOptions, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IUpdateService private readonly updateService: IUpdateService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IProductService private readonly productService: IProductService, + @IOpenerService private readonly openerService: IOpenerService, + @IDialogService private readonly dialogService: IDialogService, + @IHostService private readonly hostService: IHostService, ) { super(undefined, action, { ...options, icon: false, label: false }); + this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); } protected override getTooltip(): string | undefined { @@ -119,14 +135,33 @@ class AccountWidget extends ActionViewItem { })); this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); + // Update button (right) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button', 'sidebar-action-button'); + this.viewItemDisposables.add(this.updateHoverWidget.attachTo(this.updateButton.element)); + this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); this.viewItemDisposables.add(this.accountButton.onDidClick(e => { e?.preventDefault(); e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); } private showAccountMenu(anchor: HTMLElement): void { @@ -154,101 +189,100 @@ class AccountWidget extends ActionViewItem { : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } + private updateUpdateButton(): void { + if (!this.updateButton) { + return; + } - override onClick(): void { - // Handled by custom click handlers - } -} - -class UpdateWidget extends ActionViewItem { - - private updateButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); + const state = this.updateService.state; - constructor( - action: IAction, - options: IBaseActionViewItemOptions, - @IUpdateService private readonly updateService: IUpdateService, - ) { - super(undefined, action, { ...options, icon: false, label: false }); - } + // In the embedded app, updates are detected but cannot be installed directly. + // Show a hint button to update via VS Code only when an update is actually available. + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.classList.remove('account-widget-update-button-ready'); + this.updateButton.element.classList.add('account-widget-update-button-hint'); + this.updateButton.enabled = true; + this.updateButton.label = localize('updateAvailable', "Update Available"); + this.updateButton.element.title = localize('updateInVSCodeHover', "Updates are managed by VS Code. Click to open VS Code."); + return; + } - protected override getTooltip(): string | undefined { - return undefined; - } + if (this.shouldHideUpdateButton(state.type)) { + this.clearUpdateButtonStyling(); + this.updateButton.element.classList.add('hidden'); + return; + } - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('update-widget', 'sidebar-action'); + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.enabled = state.type === StateType.Ready; + this.updateButton.label = this.getUpdateProgressMessage(state.type); - const updateContainer = append(container, $('.update-widget-action')); - this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); - this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); - - this.updateUpdateButton(); - this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); - } + if (state.type === StateType.Ready) { + this.updateButton.element.classList.add('account-widget-update-button-ready'); + return; + } - private isUpdateReady(): boolean { - return this.updateService.state.type === StateType.Ready; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } - private isUpdatePending(): boolean { - const type = this.updateService.state.type; - return type === StateType.AvailableForDownload - || type === StateType.CheckingForUpdates - || type === StateType.Downloading - || type === StateType.Downloaded - || type === StateType.Updating - || type === StateType.Overwriting; + private shouldHideUpdateButton(type: StateType): boolean { + return type === StateType.Uninitialized + || type === StateType.Idle + || type === StateType.Disabled + || type === StateType.CheckingForUpdates; } - private updateUpdateButton(): void { - if (!this.updateButton) { - return; - } - - const state = this.updateService.state; - if (this.isUpdatePending() && !this.isUpdateReady()) { - this.updateButton.enabled = false; - this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - } else { - this.updateButton.enabled = true; - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + private clearUpdateButtonStyling(): void { + if (this.updateButton) { + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } } private getUpdateProgressMessage(type: StateType): string { switch (type) { - case StateType.CheckingForUpdates: - return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Ready: + return localize('update', "Update"); + case StateType.AvailableForDownload: case StateType.Downloading: - return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading..."); case StateType.Downloaded: - return localize('installingUpdate', "Installing Update..."); + return localize('installingUpdate', "Installing..."); case StateType.Updating: return localize('updatingApp', "Updating..."); - case StateType.Overwriting: - return localize('overwritingUpdate', "Downloading Update..."); default: return localize('updating', "Updating..."); } } private async update(): Promise { + const state = this.updateService.state; + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + const { confirmed } = await this.dialogService.confirm({ + message: localize('updateFromVSCode.title', "Update from VS Code"), + detail: localize('updateFromVSCode.detail', "This will close the Sessions app and open VS Code so you can install the update.\n\nLaunch Sessions again after the update is complete."), + primaryButton: localize('updateFromVSCode.open', "Close and Open VS Code"), + }); + if (confirmed) { + await this.openVSCode(); + await this.hostService.close(); + } + return; + } await this.updateService.quitAndInstall(); } + private async openVSCode(): Promise { + await this.openerService.open(URI.from({ + scheme: this.productService.urlProtocol, + query: 'windowId=_blank', + }), { openExternal: true }); + } + + override onClick(): void { // Handled by custom click handlers } @@ -271,11 +305,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); - const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; - this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(UpdateWidget, action, options); - }, undefined)); - // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -295,30 +324,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu } })); - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: sessionsUpdateWidgetAction, - title: localize2('sessionsUpdateWidget', 'Sessions Update'), - menu: { - id: Menus.SidebarFooter, - group: 'navigation', - order: 0, - when: ContextKeyExpr.or( - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), - ) - } - }); - } - async run(): Promise { - // Handled by the custom view item - } - })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 01bdd2c100b03..540db57894300 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,6 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +.account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +/* Hide the default action-label rendered by the toolbar — the account widget provides its own button */ +.account-widget > .action-label { + display: none; +} + /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -10,9 +31,133 @@ flex: 1; } +.account-widget-account { + overflow: hidden; + min-width: 0; + flex: 1; +} + /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +/* Boxed hint style for embedded app update indicator — outlined, no fill */ +.account-widget-update .account-widget-update-button.account-widget-update-button-hint { + background-color: transparent !important; + color: var(--vscode-button-background) !important; + border: 1px solid var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-hint:hover { + background-color: color-mix(in srgb, var(--vscode-button-background) 20%, transparent) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + font-weight: 500; +} + +.account-widget-account .account-widget-account-button { overflow: hidden; + text-overflow: ellipsis; min-width: 0; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > span:last-child { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-widget-account .account-widget-account-button > span:last-child { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css new file mode 100644 index 0000000000000..6291d8e292250 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-update-hover { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + padding: 12px 16px; +} + +.sessions-update-hover-header { + font-weight: 600; + font-size: 13px; +} + +/* Progress bar track */ +.sessions-update-hover-progress-track { + height: 4px; + border-radius: 2px; + background-color: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); + overflow: hidden; +} + +/* Progress bar fill */ +.sessions-update-hover-progress-fill { + height: 100%; + border-radius: 2px; + background-color: var(--vscode-progressBar-background, #0078d4); + transition: width 0.2s ease; +} + +/* Details grid */ +.sessions-update-hover-grid { + display: grid; + grid-template-columns: auto auto auto auto; + column-gap: 8px; + row-gap: 2px; + font-size: 12px; + align-items: baseline; +} + +.sessions-update-hover-label { + color: var(--vscode-descriptionForeground); +} + +/* Version number emphasis */ +.sessions-update-hover-version { + color: var(--vscode-textLink-foreground); +} + +/* Compact age label */ +.sessions-update-hover-age { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +/* Commit hashes - subtle */ +.sessions-update-hover-commit { + color: var(--vscode-descriptionForeground); + font-family: var(--monaco-monospace-font); + font-size: 11px; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts new file mode 100644 index 0000000000000..fc80636b0a8f7 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Downloading, IUpdate, IUpdateService, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import './media/updateHoverWidget.css'; + +export class UpdateHoverWidget { + + constructor( + private readonly updateService: IUpdateService, + private readonly productService: IProductService, + private readonly hoverService: IHoverService, + ) { } + + attachTo(target: HTMLElement) { + return this.hoverService.setupDelayedHover( + target, + () => ({ + content: this.createHoverContent(), + position: { hoverPosition: HoverPosition.RIGHT }, + appearance: { showPointer: true } + }), + { groupId: 'sessions-account-update' } + ); + } + + createHoverContent(state: State = this.updateService.state): HTMLElement { + const update = this.getUpdateFromState(state); + const currentVersion = this.productService.version ?? localize('unknownVersion', "Unknown"); + const targetVersion = update?.productVersion ?? update?.version ?? localize('unknownVersion', "Unknown"); + const currentCommit = this.productService.commit; + const targetCommit = update?.version; + const progressPercent = this.getUpdateProgressPercent(state); + + const container = document.createElement('div'); + container.classList.add('sessions-update-hover'); + + // Header: e.g. "Downloading VS Code Insiders" + const header = document.createElement('div'); + header.classList.add('sessions-update-hover-header'); + header.textContent = this.getUpdateHeaderLabel(state.type); + container.appendChild(header); + + // Progress bar + if (progressPercent !== undefined) { + const progressTrack = document.createElement('div'); + progressTrack.classList.add('sessions-update-hover-progress-track'); + const progressFill = document.createElement('div'); + progressFill.classList.add('sessions-update-hover-progress-fill'); + progressFill.style.width = `${progressPercent}%`; + progressTrack.appendChild(progressFill); + container.appendChild(progressTrack); + } + + // Version info grid + const detailsGrid = document.createElement('div'); + detailsGrid.classList.add('sessions-update-hover-grid'); + + const currentDate = this.productService.date ? new Date(this.productService.date) : undefined; + const currentAge = currentDate ? this.formatCompactAge(currentDate.getTime()) : undefined; + const newAge = update?.timestamp ? this.formatCompactAge(update.timestamp) : undefined; + + this.appendGridRow(detailsGrid, localize('updateHoverCurrentVersionLabel', "Current"), currentVersion, currentAge, currentCommit); + this.appendGridRow(detailsGrid, localize('updateHoverNewVersionLabel', "New"), targetVersion, newAge, targetCommit); + + container.appendChild(detailsGrid); + + return container; + } + + private appendGridRow(grid: HTMLElement, label: string, version: string, age?: string, commit?: string): void { + const labelEl = document.createElement('span'); + labelEl.classList.add('sessions-update-hover-label'); + labelEl.textContent = label; + grid.appendChild(labelEl); + + const versionEl = document.createElement('span'); + versionEl.classList.add('sessions-update-hover-version'); + versionEl.textContent = version; + grid.appendChild(versionEl); + + const ageEl = document.createElement('span'); + ageEl.classList.add('sessions-update-hover-age'); + ageEl.textContent = age ?? ''; + grid.appendChild(ageEl); + + const commitEl = document.createElement('span'); + commitEl.classList.add('sessions-update-hover-commit'); + commitEl.textContent = commit ? commit.substring(0, 7) : ''; + grid.appendChild(commitEl); + } + + private formatCompactAge(timestamp: number): string { + const seconds = Math.round((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return localize('compactAgeNow', "now"); + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return localize('compactAgeMinutes', "{0}m ago", minutes); + } + const hours = Math.round(seconds / 3600); + if (hours < 24) { + return localize('compactAgeHours', "{0}h ago", hours); + } + const days = Math.round(seconds / 86400); + if (days < 7) { + return localize('compactAgeDays', "{0}d ago", days); + } + const weeks = Math.round(days / 7); + if (weeks < 5) { + return localize('compactAgeWeeks', "{0}w ago", weeks); + } + const months = Math.round(days / 30); + return localize('compactAgeMonths', "{0}mo ago", months); + } + + private getUpdateFromState(state: State): IUpdate | undefined { + switch (state.type) { + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + case StateType.Overwriting: + case StateType.Updating: + return state.update; + case StateType.Downloading: + return state.update; + default: + return undefined; + } + } + + /** + * Returns progress as a percentage (0-100), or undefined if progress is not applicable. + */ + private getUpdateProgressPercent(state: State): number | undefined { + switch (state.type) { + case StateType.Downloading: { + const downloadingState = state as Downloading; + if (downloadingState.downloadedBytes !== undefined && downloadingState.totalBytes && downloadingState.totalBytes > 0) { + return Math.min(100, Math.round((downloadingState.downloadedBytes / downloadingState.totalBytes) * 100)); + } + return 0; + } + case StateType.Updating: { + const updatingState = state as Updating; + if (updatingState.currentProgress !== undefined && updatingState.maxProgress && updatingState.maxProgress > 0) { + return Math.min(100, Math.round((updatingState.currentProgress / updatingState.maxProgress) * 100)); + } + return 0; + } + case StateType.Downloaded: + case StateType.Ready: + return 100; + case StateType.AvailableForDownload: + case StateType.Overwriting: + return 0; + default: + return undefined; + } + } + + private getUpdateHeaderLabel(type: StateType): string { + const productName = this.productService.nameShort; + switch (type) { + case StateType.Ready: + return localize('updateReady', "{0} Update Ready", productName); + case StateType.AvailableForDownload: + return localize('downloadAvailable', "{0} Update Available", productName); + case StateType.Downloading: + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading {0}", productName); + case StateType.Downloaded: + return localize('installingUpdate', "Installing {0}", productName); + case StateType.Updating: + return localize('updatingApp', "Updating {0}", productName); + default: + return localize('updating', "Updating {0}", productName); + } + } +} diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts new file mode 100644 index 0000000000000..143b89aad169b --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICopilotTokenInfo, IDefaultAccount, IPolicyData } from '../../../../../base/common/defaultAccount.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State, UpdateType } from '../../../../../platform/update/common/update.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AccountWidget } from '../../browser/account.contribution.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// Import the CSS +import '../../../../browser/media/sidebarActionButton.css'; +import '../../browser/media/accountWidget.css'; + +const mockUpdate = { version: '1.0.0' }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function createMockDefaultAccountService(accountPromise: Promise): IDefaultAccountService { + const onDidChangeDefaultAccount = new Emitter(); + const onDidChangePolicyData = new Emitter(); + const onDidChangeCopilotTokenInfo = new Emitter(); + const service: IDefaultAccountService = { + _serviceBrand: undefined, + onDidChangeDefaultAccount: onDidChangeDefaultAccount.event, + onDidChangePolicyData: onDidChangePolicyData.event, + onDidChangeCopilotTokenInfo: onDidChangeCopilotTokenInfo.event, + policyData: null, + copilotTokenInfo: null, + getDefaultAccount: () => accountPromise, + getDefaultAccountAuthenticationProvider: () => ({ id: 'github', name: 'GitHub', enterprise: false }), + setDefaultAccountProvider: () => { }, + refresh: () => accountPromise, + signIn: async () => null, + signOut: async () => { }, + }; + return service; +} + +function renderAccountWidget(ctx: ComponentFixtureContext, state: State, accountPromise: Promise): void { + ctx.container.style.padding = '16px'; + ctx.container.style.width = '340px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const mockUpdateService = createMockUpdateService(state); + const mockAccountService = createMockDefaultAccountService(accountPromise); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: registerWorkbenchServices, + }); + + const action = ctx.disposableStore.add(new Action('sessions.action.accountWidget', 'Sessions Account')); + const contextMenuService = instantiationService.get(IContextMenuService); + const menuService = instantiationService.get(IMenuService); + const contextKeyService = instantiationService.get(IContextKeyService); + const hoverService = instantiationService.get(IHoverService); + const productService = instantiationService.get(IProductService); + const openerService = instantiationService.get(IOpenerService); + const dialogService = instantiationService.get(IDialogService); + const hostService = instantiationService.get(IHostService); + const widget = new AccountWidget(action, {}, mockAccountService, mockUpdateService, contextMenuService, menuService, contextKeyService, hoverService, productService, openerService, dialogService, hostService); + ctx.disposableStore.add(widget); + widget.render(ctx.container); +} + +const signedInAccount: IDefaultAccount = { + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, + accountName: 'avery.long.account.name@example.com', + sessionId: 'session-id', + enterprise: false, +}; + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + LoadingSignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), new Promise(() => { })), + }), + + SignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(null)), + }), + + SignedInNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(signedInAccount)), + }), + + CheckingForUpdatesHidden: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.CheckingForUpdates(true), Promise.resolve(signedInAccount)), + }), + + Ready: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Ready(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + AvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.AvailableForDownload(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Downloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000), Promise.resolve(signedInAccount)), + }), + + DownloadedInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloaded(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + Updating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Updating(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Overwriting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Overwriting(mockUpdate, true), Promise.resolve(signedInAccount)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts new file mode 100644 index 0000000000000..6ca47e2ee1b88 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { UpdateHoverWidget } from '../../browser/updateHoverWidget.js'; + +const mockUpdate = { version: 'a1b2c3d4e5f6', productVersion: '1.100.0', timestamp: Date.now() - 2 * 60 * 60 * 1000 }; +const mockUpdateSameVersion = { version: 'a1b2c3d4e5f6', productVersion: '1.99.0', timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function renderHoverWidget(ctx: ComponentFixtureContext, state: State): void { + ctx.container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + }); + + const updateService = createMockUpdateService(state); + const productService = new class extends mock() { + override readonly version = '1.99.0'; + override readonly nameShort = 'VS Code Insiders'; + override readonly commit = 'f0e1d2c3b4a5'; + override readonly date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + }; + const hoverService = instantiationService.get(IHoverService); + const widget = new UpdateHoverWidget(updateService, productService, hoverService); + ctx.container.appendChild(widget.createHoverContent(state)); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + UpdateHoverReady: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdate, true, false)), + }), + + UpdateHoverAvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.AvailableForDownload(mockUpdate)), + }), + + UpdateHoverDownloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), + }), + + UpdateHoverInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloaded(mockUpdate, true, false)), + }), + + UpdateHoverUpdating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Updating(mockUpdate, 40, 100)), + }), + + UpdateHoverSameVersion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdateSameVersion, true, false)), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7f5708a85d8d0..bcae4ce6990e7 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -5,19 +5,79 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; -import './agentFeedbackLineDecorationContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; -import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { hasActiveSessionAgentFeedback, registerAgentFeedbackEditorActions, submitActiveSessionFeedbackActionId } from './agentFeedbackEditorActions.js'; import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +/** + * Sets the `hasActiveSessionAgentFeedback` context key to true when the + * currently active session has pending agent feedback items. + */ +class ActiveSessionFeedbackContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFeedbackContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const contextKey = hasActiveSessionAgentFeedback.bindTo(contextKeyService); + const menuRegistration = this._register(new MutableDisposable()); + + const feedbackChanged = observableFromEvent( + this, + agentFeedbackService.onDidChangeFeedback, + e => e, + ); + + this._register(autorun(reader => { + feedbackChanged.read(reader); + const activeSession = sessionManagementService.activeSession.read(reader); + menuRegistration.clear(); + if (!activeSession) { + contextKey.set(false); + return; + } + const feedback = agentFeedbackService.getFeedback(activeSession.resource); + const count = feedback.length; + contextKey.set(count > 0); + + if (count > 0) { + menuRegistration.value = MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionApplySubmenu, { + command: { + id: submitActiveSessionFeedbackActionId, + icon: Codicon.comment, + title: localize('agentFeedback.submitFeedbackCount', "Submit Feedback ({0})", count), + }, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionAgentFeedback), + }); + } + })); + } +} + +registerWorkbenchContribution2(ActiveSessionFeedbackContextContribution.ID, ActiveSessionFeedbackContextContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index e45f96e488d06..961b2a0c57104 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -7,10 +7,8 @@ import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js' import { Codicon } from '../../../../base/common/codicons.js'; import { basename } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IRange } from '../../../../editor/common/core/range.js'; -import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../nls.js'; -import { IAgentFeedbackService, IAgentFeedback } from './agentFeedbackService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -28,13 +26,9 @@ export class AgentFeedbackAttachmentContribution extends Disposable { /** Track onDidAcceptInput subscriptions per widget session */ private readonly _widgetListeners = this._store.add(new DisposableMap()); - /** Cache of resolved code snippets keyed by feedback ID */ - private readonly _snippetCache = new Map(); - constructor( @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @ITextModelService private readonly _textModelService: ITextModelService, ) { super(); @@ -55,11 +49,10 @@ export class AgentFeedbackAttachmentContribution extends Disposable { if (feedbackItems.length === 0) { widget.attachmentModel.delete(attachmentId); - this._snippetCache.clear(); return; } - const value = await this._buildFeedbackValue(feedbackItems); + const value = this._buildFeedbackValue(feedbackItems); const entry: IAgentFeedbackVariableEntry = { kind: 'agentFeedback', @@ -74,6 +67,9 @@ export class AgentFeedbackAttachmentContribution extends Disposable { text: f.text, resourceUri: f.resourceUri, range: f.range, + codeSelection: f.codeSelection, + diffHunks: f.diffHunks, + sourcePRReviewCommentId: f.sourcePRReviewCommentId, })), value, }; @@ -84,41 +80,26 @@ export class AgentFeedbackAttachmentContribution extends Disposable { } /** - * Builds a rich string value for the agent feedback attachment that includes - * the code snippet at each feedback item's location alongside the feedback text. - * Uses a cache keyed by feedback ID to avoid re-resolving snippets for - * items that haven't changed. + * Builds a rich string value for the agent feedback attachment from + * the selection and diff context already stored on each feedback item. */ - private async _buildFeedbackValue(feedbackItems: readonly IAgentFeedback[]): Promise { - // Prune stale cache entries for items that no longer exist - const currentIds = new Set(feedbackItems.map(f => f.id)); - for (const cachedId of this._snippetCache.keys()) { - if (!currentIds.has(cachedId)) { - this._snippetCache.delete(cachedId); - } - } - - // Resolve only new (uncached) snippets - const uncachedItems = feedbackItems.filter(f => !this._snippetCache.has(f.id)); - if (uncachedItems.length > 0) { - await Promise.all(uncachedItems.map(async f => { - const snippet = await this._getCodeSnippet(f.resourceUri, f.range); - this._snippetCache.set(f.id, snippet); - })); - } - - // Build the final string from cache + private _buildFeedbackValue(feedbackItems: IAgentFeedbackVariableEntry['feedbackItems']): string { const parts: string[] = ['The following comments were made on the code changes:']; for (const item of feedbackItems) { - const codeSnippet = this._snippetCache.get(item.id); const fileName = basename(item.resourceUri); const lineRef = item.range.startLineNumber === item.range.endLineNumber ? `${item.range.startLineNumber}` : `${item.range.startLineNumber}-${item.range.endLineNumber}`; let part = `[${fileName}:${lineRef}]`; - if (codeSnippet) { - part += `\n\`\`\`\n${codeSnippet}\n\`\`\``; + if (item.sourcePRReviewCommentId) { + part += `\n(PR review comment, thread ID: ${item.sourcePRReviewCommentId} — resolve this thread when addressed)`; + } + if (item.codeSelection) { + part += `\nSelection:\n\`\`\`\n${item.codeSelection}\n\`\`\``; + } + if (item.diffHunks) { + part += `\nDiff Hunks:\n\`\`\`diff\n${item.diffHunks}\n\`\`\``; } part += `\nComment: ${item.text}`; parts.push(part); @@ -127,23 +108,6 @@ export class AgentFeedbackAttachmentContribution extends Disposable { return parts.join('\n\n'); } - /** - * Resolves the text model for a resource and extracts the code in the given range. - * Returns undefined if the model cannot be resolved. - */ - private async _getCodeSnippet(resourceUri: URI, range: IRange): Promise { - try { - const ref = await this._textModelService.createModelReference(resourceUri); - try { - return ref.object.textEditorModel.getValueInRange(range); - } finally { - ref.dispose(); - } - } catch { - return undefined; - } - } - /** * Ensure we listen for the chat widget's submit event so we can clear feedback after send. */ diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentWidget.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentWidget.ts index fb2b68188e315..33a887638e83f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentWidget.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachmentWidget.ts @@ -73,6 +73,6 @@ export class AgentFeedbackAttachmentWidget extends Disposable { this.element.ariaLabel = localize('chat.agentFeedback', "Attached agent feedback, {0}", this._attachment.name); // Custom interactive hover - this._store.add(this._instantiationService.createInstance(AgentFeedbackHover, this.element, this._attachment)); + this._store.add(this._instantiationService.createInstance(AgentFeedbackHover, this.element, this._attachment, options.supportsDeletion)); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index 620cf99ae3db9..67e3057e82713 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -6,24 +6,34 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { EditorsOrder, IEditorIdentifier } from '../../../../workbench/common/editor.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { GroupsOrder, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; +import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments } from './sessionEditorComments.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; export const navigateNextFeedbackActionId = 'agentFeedbackEditor.action.navigateNext'; export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; +export const hasSessionEditorComments = new RawContextKey('agentFeedbackEditor.hasSessionComments', false); +export const hasSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasAgentFeedback', false); +export const hasActiveSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasActiveSessionAgentFeedback', false); +export const submitActiveSessionFeedbackActionId = 'agentFeedbackEditor.action.submitActiveSession'; abstract class AgentFeedbackEditorAction extends Action2 { @@ -37,16 +47,33 @@ abstract class AgentFeedbackEditorAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatEditingService = accessor.get(IChatEditingService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const codeReviewService = accessor.get(ICodeReviewService); - const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); - const sessionResource = candidates - .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) - .find((value): value is URI => !!value); - if (!sessionResource) { - return; - } + const editorGroupsService = accessor.get(IEditorGroupsService); + + const activePane = editorService.activeEditorPane + ?? editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).find(g => g.activeEditorPane)?.activeEditorPane + ?? editorService.visibleEditorPanes[0]; + const candidates = getActiveResourceCandidates(activePane?.input); + for (const candidate of candidates) { + const sessionResource = getSessionForResource(candidate, chatEditingService, sessionsManagementService) + ?? agentFeedbackService.getMostRecentSessionForResource(candidate); + if (!sessionResource) { + continue; + } - return this.runWithSession(accessor, sessionResource); + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); + if (comments.length > 0) { + return this.runWithSession(accessor, sessionResource); + } + } } abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; @@ -65,7 +92,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 0, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -74,9 +101,11 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { const chatWidgetService = accessor.get(IChatWidgetService); const agentFeedbackService = accessor.get(IAgentFeedbackService); const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); return; } @@ -95,7 +124,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { await editorService.closeEditors(editorsToClose); } - await widget.acceptInput('Act on the provided feedback'); + await widget.acceptInput('act on feedback'); // move to use /act-on-feedback when the bug is fixed } } @@ -114,27 +143,27 @@ class NavigateFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'navigate', order: _next ? 2 : 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }, }); } - override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { const agentFeedbackService = accessor.get(IAgentFeedbackService); - const editorService = accessor.get(IEditorService); + const codeReviewService = accessor.get(ICodeReviewService); + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); - const feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); - if (!feedback) { + const comment = agentFeedbackService.getNextNavigableItem(sessionResource, comments, this._next); + if (!comment) { return; } - editorService.openEditor({ - resource: feedback.resourceUri, - options: { - preserveFocus: false, - revealIfVisible: true, - } - }); + await agentFeedbackService.revealSessionComment(sessionResource, comment.id, comment.resourceUri, comment.range); } } @@ -152,7 +181,7 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -163,8 +192,66 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { } } +class SubmitActiveSessionFeedbackAction extends Action2 { + + static readonly ID = submitActiveSessionFeedbackActionId; + + constructor() { + super({ + id: SubmitActiveSessionFeedbackAction.ID, + title: localize2('agentFeedback.submitFeedback', 'Submit Feedback'), + icon: Codicon.comment, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionAgentFeedback), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatWidgetService = accessor.get(IChatWidgetService); + const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.activeSession.get(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const feedbackItems = agentFeedbackService.getFeedback(sessionResource); + if (feedbackItems.length === 0) { + return; + } + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('act on feedback'); + } +} + export function registerAgentFeedbackEditorActions(): void { registerAction2(SubmitFeedbackAction); + registerAction2(SubmitActiveSessionFeedbackAction); registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); registerAction2(ClearAllFeedbackAction); @@ -177,6 +264,6 @@ export function registerAgentFeedbackEditorActions(): void { }, group: 'navigate', order: -1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 6e733fd5a1015..ae5fa614c3d75 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -5,19 +5,25 @@ import './media/agentFeedbackEditorInput.css'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditor, IDiffEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; -import { SelectionDirection } from '../../../../editor/common/core/selection.js'; +import { Selection, SelectionDirection } from '../../../../editor/common/core/selection.js'; import { URI } from '../../../../base/common/uri.js'; -import { addStandardDisposableListener, getWindow } from '../../../../base/browser/dom.js'; +import { addStandardDisposableListener, getWindow, ModifierKeyEmitter } from '../../../../base/browser/dom.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { getSessionForResource } from './agentFeedbackEditorUtils.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { localize } from '../../../../nls.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; class AgentFeedbackInputWidget implements IOverlayWidget { @@ -30,9 +36,18 @@ class AgentFeedbackInputWidget implements IOverlayWidget { private readonly _domNode: HTMLElement; private readonly _inputElement: HTMLTextAreaElement; private readonly _measureElement: HTMLElement; + private readonly _actionBar: ActionBar; + private readonly _addAction: Action; + private readonly _addAndSubmitAction: Action; private _position: IOverlayWidgetPosition | null = null; private _lineHeight = 0; + private readonly _onDidTriggerAdd = new Emitter(); + readonly onDidTriggerAdd: Event = this._onDidTriggerAdd.event; + + private readonly _onDidTriggerAddAndSubmit = new Emitter(); + readonly onDidTriggerAddAndSubmit: Event = this._onDidTriggerAddAndSubmit.event; + constructor( private readonly _editor: ICodeEditor, ) { @@ -50,9 +65,54 @@ class AgentFeedbackInputWidget implements IOverlayWidget { this._measureElement.classList.add('agent-feedback-input-measure'); this._domNode.appendChild(this._measureElement); + // Action bar with add/submit actions + const actionsContainer = document.createElement('div'); + actionsContainer.classList.add('agent-feedback-input-actions'); + this._domNode.appendChild(actionsContainer); + + this._addAction = new Action( + 'agentFeedback.add', + localize('agentFeedback.add', "Add Feedback (Enter)"), + ThemeIcon.asClassName(Codicon.plus), + false, + () => { this._onDidTriggerAdd.fire(); return Promise.resolve(); } + ); + + this._addAndSubmitAction = new Action( + 'agentFeedback.addAndSubmit', + localize('agentFeedback.addAndSubmit', "Add Feedback and Submit (Alt+Enter)"), + ThemeIcon.asClassName(Codicon.send), + false, + () => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); } + ); + + this._actionBar = new ActionBar(actionsContainer); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + + // Toggle to alt action when Alt key is held + const modifierKeyEmitter = ModifierKeyEmitter.getInstance(); + modifierKeyEmitter.event(status => { + this._updateActionForAlt(status.altKey); + }); + this._editor.applyFontInfo(this._inputElement); this._editor.applyFontInfo(this._measureElement); - this._lineHeight = this._editor.getOption(EditorOption.lineHeight); + this._lineHeight = 22; + this._inputElement.style.lineHeight = `${this._lineHeight}px`; + } + + private _isShowingAlt = false; + + private _updateActionForAlt(altKey: boolean): void { + if (altKey && !this._isShowingAlt) { + this._isShowingAlt = true; + this._actionBar.clear(); + this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") }); + } else if (!altKey && this._isShowingAlt) { + this._isShowingAlt = false; + this._actionBar.clear(); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + } } getId(): string { @@ -86,6 +146,7 @@ class AgentFeedbackInputWidget implements IOverlayWidget { clearInput(): void { this._inputElement.value = ''; + this._updateActionEnabled(); this._autoSize(); } @@ -93,6 +154,16 @@ class AgentFeedbackInputWidget implements IOverlayWidget { this._autoSize(); } + updateActionEnabled(): void { + this._updateActionEnabled(); + } + + private _updateActionEnabled(): void { + const hasText = this._inputElement.value.trim().length > 0; + this._addAction.enabled = hasText; + this._addAndSubmitAction.enabled = hasText; + } + private _autoSize(): void { const text = this._inputElement.value || this._inputElement.placeholder; @@ -109,6 +180,14 @@ class AgentFeedbackInputWidget implements IOverlayWidget { const newHeight = Math.max(this._inputElement.scrollHeight, this._lineHeight); this._inputElement.style.height = `${newHeight}px`; } + + dispose(): void { + this._actionBar.dispose(); + this._addAction.dispose(); + this._addAndSubmitAction.dispose(); + this._onDidTriggerAdd.dispose(); + this._onDidTriggerAddAndSubmit.dispose(); + } } export class AgentFeedbackEditorInputContribution extends Disposable implements IEditorContribution { @@ -118,6 +197,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements private _widget: AgentFeedbackInputWidget | undefined; private _visible = false; private _mouseDown = false; + private _suppressSelectionChangeOnce = false; private _sessionResource: URI | undefined; private readonly _widgetListeners = this._store.add(new DisposableStore()); @@ -125,7 +205,8 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements private readonly _editor: ICodeEditor, @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, ) { super(); @@ -165,7 +246,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._hide(); }, 0); })); - this._store.add(this._editor.onDidFocusEditorWidget(() => this._onSelectionChanged())); + this._store.add(this._editor.onDidFocusEditorText(() => this._onSelectionChanged())); } private _isWidgetTarget(target: EventTarget | Element | null): boolean { @@ -175,6 +256,8 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements private _ensureWidget(): AgentFeedbackInputWidget { if (!this._widget) { this._widget = new AgentFeedbackInputWidget(this._editor); + this._store.add(this._widget.onDidTriggerAdd(() => this._addFeedback())); + this._store.add(this._widget.onDidTriggerAddAndSubmit(() => this._addFeedbackAndSubmit())); this._editor.addOverlayWidget(this._widget); } return this._widget; @@ -182,16 +265,22 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements private _onModelChanged(): void { this._hide(); + this._suppressSelectionChangeOnce = false; this._sessionResource = undefined; } private _onSelectionChanged(): void { - if (this._mouseDown || !this._editor.hasWidgetFocus()) { + if (this._suppressSelectionChangeOnce) { + this._suppressSelectionChangeOnce = false; + return; + } + + if (this._mouseDown || !this._editor.hasTextFocus()) { return; } const selection = this._editor.getSelection(); - if (!selection || selection.isEmpty()) { + if (!selection || (selection.isEmpty() && !this._getDiffHunkForSelection(selection))) { this._hide(); return; } @@ -202,7 +291,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } - const sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); + const sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService); if (!sessionResource) { this._hide(); return; @@ -251,13 +340,14 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } - // Don't focus if a modifier key is pressed alone - if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) { + // Only steal focus when the editor text area itself is focused, + // not when an overlay widget (e.g. find widget) has focus + if (!this._editor.hasTextFocus()) { return; } - // Don't focus if any modifier is held (keyboard shortcuts) - if (e.ctrlKey || e.altKey || e.metaKey) { + // Don't focus if a modifier key is pressed alone + if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) { return; } @@ -268,6 +358,35 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } + // Ctrl+I / Cmd+I explicitly focuses the feedback input + if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyI) { + e.preventDefault(); + e.stopPropagation(); + widget.inputElement.focus(); + return; + } + + // Don't focus if any modifier is held (keyboard shortcuts) + if (e.ctrlKey || e.altKey || e.metaKey) { + return; + } + + // Keep caret/navigation keys in the editor. Only actual typing should move focus. + if ( + e.keyCode === KeyCode.UpArrow + || e.keyCode === KeyCode.DownArrow + || e.keyCode === KeyCode.LeftArrow + || e.keyCode === KeyCode.RightArrow + ) { + return; + } + + // Only auto-focus the input on typing when the document is readonly; + // when editable the user must click or use Ctrl+I to focus. + if (!this._editor.getOption(EditorOption.readOnly)) { + return; + } + // If the input is not focused, focus it and let the keystroke go through if (getWindow(widget.inputElement).document.activeElement !== widget.inputElement) { widget.inputElement.focus(); @@ -285,10 +404,17 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } + if (e.keyCode === KeyCode.Enter && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._addFeedbackAndSubmit(); + return; + } + if (e.keyCode === KeyCode.Enter) { e.preventDefault(); e.stopPropagation(); - this._submit(widget); + this._addFeedback(); return; } })); @@ -301,6 +427,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements // Auto-size the textarea as the user types this._widgetListeners.add(addStandardDisposableListener(widget.inputElement, 'input', () => { widget.autoSize(); + widget.updateActionEnabled(); this._updatePosition(); })); @@ -319,8 +446,45 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements })); } - private _submit(widget: AgentFeedbackInputWidget): void { - const text = widget.inputElement.value.trim(); + focusInput(): void { + if (this._visible && this._widget) { + this._widget.inputElement.focus(); + } + } + + private _hideAndRefocusEditor(): void { + this._suppressSelectionChangeOnce = true; + this._hide(); + this._editor.focus(); + } + + private _addFeedback(): boolean { + if (!this._widget) { + return false; + } + + const text = this._widget.inputElement.value.trim(); + if (!text) { + return false; + } + + const selection = this._editor.getSelection(); + const model = this._editor.getModel(); + if (!selection || !model || !this._sessionResource) { + return false; + } + + this._agentFeedbackService.addFeedback(this._sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection)); + this._hideAndRefocusEditor(); + return true; + } + + private _addFeedbackAndSubmit(): void { + if (!this._widget) { + return; + } + + const text = this._widget.inputElement.value.trim(); if (!text) { return; } @@ -331,9 +495,54 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } - this._agentFeedbackService.addFeedback(this._sessionResource, model.uri, selection, text); - this._hide(); - this._editor.focus(); + const sessionResource = this._sessionResource; + this._hideAndRefocusEditor(); + this._agentFeedbackService.addFeedbackAndSubmit(sessionResource, model.uri, selection, text, undefined, createAgentFeedbackContext(this._editor, this._codeEditorService, model.uri, selection)); + } + + private _getContainingDiffEditor(): IDiffEditor | undefined { + return this._codeEditorService.listDiffEditors().find(diffEditor => + diffEditor.getModifiedEditor() === this._editor || diffEditor.getOriginalEditor() === this._editor + ); + } + + private _getDiffHunkForSelection(selection: Selection): { startLineNumber: number; endLineNumberExclusive: number } | undefined { + if (!selection.isEmpty()) { + return undefined; + } + + const diffEditor = this._getContainingDiffEditor(); + if (!diffEditor) { + return undefined; + } + + const diffResult = diffEditor.getDiffComputationResult(); + if (!diffResult) { + return undefined; + } + + const position = selection.getStartPosition(); + const lineNumber = position.lineNumber; + const isModifiedEditor = diffEditor.getModifiedEditor() === this._editor; + for (const change of diffResult.changes2) { + const lineRange = isModifiedEditor ? change.modified : change.original; + if (!lineRange.isEmpty && lineRange.contains(lineNumber)) { + // Don't show when cursor is at the start or end position of the hunk + const isAtHunkStart = lineNumber === lineRange.startLineNumber && position.column === 1; + const lastHunkLine = lineRange.endLineNumberExclusive - 1; + const model = this._editor.getModel(); + const isAtHunkEnd = model && lineNumber === lastHunkLine && position.column === model.getLineMaxColumn(lastHunkLine); + if (isAtHunkStart || isAtHunkEnd) { + return undefined; + } + return { + startLineNumber: lineRange.startLineNumber, + endLineNumberExclusive: lineRange.endLineNumberExclusive, + }; + } + } + + return undefined; } private _updatePosition(): void { @@ -342,11 +551,50 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements } const selection = this._editor.getSelection(); - if (!selection || selection.isEmpty()) { + if (!selection) { this._hide(); return; } + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const layoutInfo = this._editor.getLayoutInfo(); + const widgetDom = this._widget.getDomNode(); + const widgetHeight = widgetDom.offsetHeight || 30; + const widgetWidth = widgetDom.offsetWidth || 150; + + if (selection.isEmpty()) { + const diffHunk = this._getDiffHunkForSelection(selection); + if (!diffHunk) { + this._hide(); + return; + } + + const cursorPosition = selection.getStartPosition(); + const scrolledPosition = this._editor.getScrolledVisiblePosition(cursorPosition); + if (!scrolledPosition) { + this._widget.setPosition(null); + return; + } + + const hunkLineCount = diffHunk.endLineNumberExclusive - diffHunk.startLineNumber; + const cursorLineOffset = cursorPosition.lineNumber - diffHunk.startLineNumber; + const topHalfLineCount = Math.ceil(hunkLineCount / 2); + const top = hunkLineCount < 10 + ? cursorLineOffset < topHalfLineCount + ? scrolledPosition.top - (cursorLineOffset * lineHeight) - widgetHeight + : scrolledPosition.top + ((diffHunk.endLineNumberExclusive - cursorPosition.lineNumber) * lineHeight) + : scrolledPosition.top - widgetHeight; + const left = Math.max(0, Math.min(scrolledPosition.left, layoutInfo.width - widgetWidth)); + + this._widget.setPosition({ + preference: { + top: Math.max(0, Math.min(top, layoutInfo.height - widgetHeight)), + left, + } + }); + return; + } + const cursorPosition = selection.getDirection() === SelectionDirection.LTR ? selection.getEndPosition() : selection.getStartPosition(); @@ -357,12 +605,6 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } - const lineHeight = this._editor.getOption(EditorOption.lineHeight); - const layoutInfo = this._editor.getLayoutInfo(); - const widgetDom = this._widget.getDomNode(); - const widgetHeight = widgetDom.offsetHeight || 30; - const widgetWidth = widgetDom.offsetWidth || 150; - // Compute vertical position, flipping if out of bounds let top: number; if (selection.getDirection() === SelectionDirection.LTR) { @@ -393,6 +635,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements override dispose(): void { if (this._widget) { this._editor.removeOverlayWidget(this._widget); + this._widget.dispose(); this._widget = undefined; } super.dispose(); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 56cb43ad9347d..28aa27175ffd0 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -18,13 +18,15 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { EditorGroupView } from '../../../../workbench/browser/parts/editor/editorGroupView.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; +import { hasSessionAgentFeedback, hasSessionEditorComments, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; import { assertType } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments, hasAgentFeedbackComments } from './sessionEditorComments.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; class AgentFeedbackActionViewItem extends ActionViewItem { @@ -54,7 +56,7 @@ class AgentFeedbackActionViewItem extends ActionViewItem { } } -class AgentFeedbackOverlayWidget extends Disposable { +export class AgentFeedbackOverlayWidget extends Disposable { private readonly _domNode: HTMLElement; private readonly _toolbarNode: HTMLElement; @@ -142,9 +144,11 @@ class AgentFeedbackOverlayController { container: HTMLElement, group: IEditorGroup, @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, - @IAgentSessionsService agentSessionsService: IAgentSessionsService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, @IInstantiationService instaService: IInstantiationService, @IChatEditingService chatEditingService: IChatEditingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ICodeReviewService codeReviewService: ICodeReviewService, ) { this._domNode.classList.add('agent-feedback-editor-overlay'); this._domNode.style.position = 'absolute'; @@ -155,6 +159,8 @@ class AgentFeedbackOverlayController { const widget = this._store.add(instaService.createInstance(AgentFeedbackOverlayWidget)); this._domNode.appendChild(widget.getDomNode()); this._store.add(toDisposable(() => this._domNode.remove())); + const hasCommentsContext = hasSessionEditorComments.bindTo(contextKeyService); + const hasAgentFeedbackContext = hasSessionAgentFeedback.bindTo(contextKeyService); const show = () => { if (!container.contains(this._domNode)) { @@ -181,19 +187,35 @@ class AgentFeedbackOverlayController { const candidates = getActiveResourceCandidates(group.activeEditorPane?.input); let navigationBearings = undefined; + let hasAgentFeedback = false; for (const candidate of candidates) { - const sessionResource = getSessionForResource(candidate, chatEditingService, agentSessionsService); - if (sessionResource && agentFeedbackService.getFeedback(sessionResource).length > 0) { - navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource); + const sessionResource = getSessionForResource(candidate, chatEditingService, sessionsManagementService); + if (!sessionResource) { + continue; + } + + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).read(r), + codeReviewService.getPRReviewState(sessionResource).read(r), + ); + if (comments.length > 0) { + navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource, comments); + hasAgentFeedback = hasAgentFeedbackComments(comments); break; } } if (!navigationBearings) { + hasCommentsContext.set(false); + hasAgentFeedbackContext.set(false); hide(); return; } + hasCommentsContext.set(true); + hasAgentFeedbackContext.set(hasAgentFeedback); widget.show(navigationBearings); show(); })); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts index bb42a4ff24241..5085864a03cee 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorUtils.ts @@ -4,27 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ICodeEditor, IDiffEditor } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; +import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; /** - * Find the session that contains the given resource by checking editing sessions and agent sessions. + * Find the session that contains the given resource by checking editing sessions, + * sessions providers, and agent sessions. */ export function getSessionForResource( resourceUri: URI, chatEditingService: IChatEditingService, - agentSessionsService: IAgentSessionsService, + sessionsManagementService: ISessionsManagementService, ): URI | undefined { for (const editingSession of chatEditingService.editingSessionsObs.get()) { if (editingEntriesContainResource(editingSession.entries.get(), resourceUri)) { return editingSession.chatSessionResource; } } - - for (const session of agentSessionsService.model.sessions) { - if (agentSessionContainsResource(session, resourceUri)) { + for (const session of sessionsManagementService.getSessions()) { + const changes = session.changes.get(); + if (changes.some(change => changeMatchesResource(change, resourceUri))) { return session.resource; } } @@ -32,6 +39,280 @@ export function getSessionForResource( return undefined; } +export type AgentFeedbackSessionChange = IChatSessionFileChange | IChatSessionFileChange2; + +export interface IAgentFeedbackContext { + readonly codeSelection?: string; + readonly diffHunks?: string; +} + +export function changeMatchesResource(change: AgentFeedbackSessionChange, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; +} + +export function getSessionChangeForResource( + sessionResource: URI | undefined, + resourceUri: URI, + sessionsManagementService: ISessionsManagementService, +): AgentFeedbackSessionChange | undefined { + if (!sessionResource) { + return undefined; + } + + const sessionData = sessionsManagementService.getSession(sessionResource); + if (sessionData) { + const changes = sessionData.changes.get(); + return changes.find(change => changeMatchesResource(change, resourceUri)); + } + + return undefined; +} + +export function createAgentFeedbackContext( + editor: ICodeEditor, + codeEditorService: ICodeEditorService, + resourceUri: URI, + range: IRange, +): IAgentFeedbackContext { + const codeSelection = getCodeSelection(editor, codeEditorService, resourceUri, range); + const diffHunks = getDiffHunks(editor, codeEditorService, resourceUri, range); + return { codeSelection, diffHunks }; +} + +function getCodeSelection( + editor: ICodeEditor, + codeEditorService: ICodeEditorService, + resourceUri: URI, + range: IRange, +): string | undefined { + const model = getModelForResource(editor, codeEditorService, resourceUri); + if (!model) { + return undefined; + } + + const selection = model.getValueInRange(range); + return selection.length > 0 ? selection : undefined; +} + +function getDiffHunks( + editor: ICodeEditor, + codeEditorService: ICodeEditorService, + resourceUri: URI, + range: IRange, +): string | undefined { + const diffEditor = getContainingDiffEditor(editor, codeEditorService); + if (!diffEditor) { + return undefined; + } + + const originalModel = diffEditor.getOriginalEditor().getModel(); + const modifiedModel = diffEditor.getModifiedEditor().getModel(); + if (!originalModel || !modifiedModel) { + return undefined; + } + + const selectionIsInOriginal = isEqual(resourceUri, originalModel.uri); + const selectionIsInModified = isEqual(resourceUri, modifiedModel.uri); + if (!selectionIsInOriginal && !selectionIsInModified) { + return undefined; + } + + const diffResult = diffEditor.getDiffComputationResult(); + if (!diffResult) { + return undefined; + } + + const selectionIsEmpty = range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn; + const relevantGroups = groupChanges(diffResult.changes2).filter(group => { + const changeTouchesSelection = (change: DetailedLineRangeMapping) => rangeTouchesChange(range, selectionIsInOriginal ? change.original : change.modified); + return selectionIsEmpty ? group.some(changeTouchesSelection) : group.every(changeTouchesSelection); + }); + if (relevantGroups.length === 0) { + return undefined; + } + + const originalText = originalModel.getValue(); + const modifiedText = modifiedModel.getValue(); + const originalEndsWithNewline = originalText.length > 0 && originalText.endsWith('\n'); + const modifiedEndsWithNewline = modifiedText.length > 0 && modifiedText.endsWith('\n'); + const originalLines = originalText.split('\n'); + const modifiedLines = modifiedText.split('\n'); + + if (originalEndsWithNewline && originalLines[originalLines.length - 1] === '') { + originalLines.pop(); + } + if (modifiedEndsWithNewline && modifiedLines[modifiedLines.length - 1] === '') { + modifiedLines.pop(); + } + + return relevantGroups.map(group => renderHunkGroup(group, originalLines, modifiedLines, originalEndsWithNewline, modifiedEndsWithNewline)).join('\n'); +} + +function getContainingDiffEditor(editor: ICodeEditor, codeEditorService: ICodeEditorService): IDiffEditor | undefined { + return codeEditorService.listDiffEditors().find(diffEditor => + diffEditor.getModifiedEditor() === editor || diffEditor.getOriginalEditor() === editor + ); +} + +function getModelForResource(editor: ICodeEditor, codeEditorService: ICodeEditorService, resourceUri: URI) { + const currentModel = editor.getModel(); + if (currentModel && isEqual(currentModel.uri, resourceUri)) { + return currentModel; + } + + const diffEditor = getContainingDiffEditor(editor, codeEditorService); + const originalModel = diffEditor?.getOriginalEditor().getModel(); + if (originalModel && isEqual(originalModel.uri, resourceUri)) { + return originalModel; + } + + const modifiedModel = diffEditor?.getModifiedEditor().getModel(); + if (modifiedModel && isEqual(modifiedModel.uri, resourceUri)) { + return modifiedModel; + } + + return undefined; +} + +function groupChanges(changes: readonly DetailedLineRangeMapping[]): DetailedLineRangeMapping[][] { + const contextSize = 3; + const groups: DetailedLineRangeMapping[][] = []; + let currentGroup: DetailedLineRangeMapping[] = []; + + for (const change of changes) { + if (currentGroup.length === 0) { + currentGroup.push(change); + continue; + } + + const lastChange = currentGroup[currentGroup.length - 1]; + const lastContextEnd = lastChange.original.endLineNumberExclusive - 1 + contextSize; + const currentContextStart = change.original.startLineNumber - contextSize; + if (currentContextStart <= lastContextEnd + 1) { + currentGroup.push(change); + } else { + groups.push(currentGroup); + currentGroup = [change]; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +function rangeTouchesChange( + range: IRange, + lineRange: { startLineNumber: number; endLineNumberExclusive: number; isEmpty: boolean; contains(lineNumber: number): boolean }, +): boolean { + const isEmptySelection = range.startLineNumber === range.endLineNumber && range.startColumn === range.endColumn; + if (isEmptySelection) { + return !lineRange.isEmpty && lineRange.contains(range.startLineNumber); + } + + const selectionStart = range.startLineNumber; + const selectionEndExclusive = range.endLineNumber + 1; + return selectionStart <= lineRange.startLineNumber && lineRange.endLineNumberExclusive <= selectionEndExclusive; +} + +function renderHunkGroup( + group: readonly DetailedLineRangeMapping[], + originalLines: string[], + modifiedLines: string[], + originalEndsWithNewline: boolean, + modifiedEndsWithNewline: boolean, +): string { + const contextSize = 3; + const firstChange = group[0]; + const lastChange = group[group.length - 1]; + const hunkOrigStart = Math.max(1, firstChange.original.startLineNumber - contextSize); + const hunkOrigEnd = Math.min(originalLines.length, lastChange.original.endLineNumberExclusive - 1 + contextSize); + const hunkModStart = Math.max(1, firstChange.modified.startLineNumber - contextSize); + + const hunkLines: string[] = []; + let lastOriginalLineIndex = -1; + let lastModifiedLineIndex = -1; + let origLineNum = hunkOrigStart; + let origCount = 0; + let modCount = 0; + + for (const change of group) { + const origStart = change.original.startLineNumber; + const origEnd = change.original.endLineNumberExclusive; + const modStart = change.modified.startLineNumber; + const modEnd = change.modified.endLineNumberExclusive; + + while (origLineNum < origStart) { + const idx = hunkLines.length; + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + if (origLineNum === originalLines.length) { + lastOriginalLineIndex = idx; + } + const modLineNum = hunkModStart + modCount; + if (modLineNum === modifiedLines.length) { + lastModifiedLineIndex = idx; + } + origLineNum++; + origCount++; + modCount++; + } + + for (let i = origStart; i < origEnd; i++) { + const idx = hunkLines.length; + hunkLines.push(`-${originalLines[i - 1]}`); + if (i === originalLines.length) { + lastOriginalLineIndex = idx; + } + origLineNum++; + origCount++; + } + + for (let i = modStart; i < modEnd; i++) { + const idx = hunkLines.length; + hunkLines.push(`+${modifiedLines[i - 1]}`); + if (i === modifiedLines.length) { + lastModifiedLineIndex = idx; + } + modCount++; + } + } + + while (origLineNum <= hunkOrigEnd) { + const idx = hunkLines.length; + hunkLines.push(` ${originalLines[origLineNum - 1]}`); + if (origLineNum === originalLines.length) { + lastOriginalLineIndex = idx; + } + const modLineNum = hunkModStart + modCount; + if (modLineNum === modifiedLines.length) { + lastModifiedLineIndex = idx; + } + origLineNum++; + origCount++; + modCount++; + } + + const header = `@@ -${hunkOrigStart},${origCount} +${hunkModStart},${modCount} @@`; + const result = [header, ...hunkLines]; + + if (!originalEndsWithNewline && lastOriginalLineIndex >= 0) { + result.splice(lastOriginalLineIndex + 2, 0, '\\ No newline at end of file'); + } else if (!modifiedEndsWithNewline && lastModifiedLineIndex >= 0) { + result.splice(lastModifiedLineIndex + 2, 0, '\\ No newline at end of file'); + } + + return result.join('\n'); +} + export function getActiveResourceCandidates(input: Parameters[0]): URI[] { const result: URI[] = []; const resources = EditorResourceAccessor.getOriginalUri(input, { supportSideBySide: SideBySideEditor.BOTH }); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 5232f8633eef4..ef11a8b218018 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -5,59 +5,43 @@ import './media/agentFeedbackEditorWidget.css'; +import { Action } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, addStandardDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js'; import { URI } from '../../../../base/common/uri.js'; import { Range } from '../../../../editor/common/core/range.js'; import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js'; import { OverviewRulerLane } from '../../../../editor/common/model.js'; import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import * as nls from '../../../../nls.js'; -import { IAgentFeedback, IAgentFeedbackService } from './agentFeedbackService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { getSessionForResource } from './agentFeedbackEditorUtils.js'; - -/** - * Groups nearby feedback items within a threshold number of lines. - */ -function groupNearbyFeedback(items: readonly IAgentFeedback[], lineThreshold: number = 5): IAgentFeedback[][] { - if (items.length === 0) { - return []; - } - - // Sort by start line number - const sorted = [...items].sort((a, b) => a.range.startLineNumber - b.range.startLineNumber); - - const groups: IAgentFeedback[][] = []; - let currentGroup: IAgentFeedback[] = [sorted[0]]; - - for (let i = 1; i < sorted.length; i++) { - const firstItem = currentGroup[0]; - const currentItem = sorted[i]; - - const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; - - if (verticalSpan <= lineThreshold) { - currentGroup.push(currentItem); - } else { - groups.push(currentGroup); - currentGroup = [currentItem]; - } - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - return groups; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { createAgentFeedbackContext, getSessionForResource } from './agentFeedbackEditorUtils.js'; +import { ICodeReviewService, IPRReviewState } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments, groupNearbySessionEditorComments, ISessionEditorComment, SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; + +interface ICommentItemActions { + editAction: Action; + convertAction: Action | undefined; + removeAction: Action; } /** @@ -72,7 +56,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid private readonly _domNode: HTMLElement; private readonly _headerNode: HTMLElement; private readonly _titleNode: HTMLElement; - private readonly _dismissButton: HTMLElement; private readonly _toggleButton: HTMLElement; private readonly _bodyNode: HTMLElement; private readonly _itemElements = new Map(); @@ -87,9 +70,12 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid constructor( private readonly _editor: ICodeEditor, - private readonly _feedbackItems: readonly IAgentFeedback[], - private readonly _agentFeedbackService: IAgentFeedbackService, + private readonly _commentItems: readonly ISessionEditorComment[], private readonly _sessionResource: URI, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, ) { super(); @@ -115,12 +101,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._updateToggleButton(); this._headerNode.appendChild(this._toggleButton); - // Dismiss button - this._dismissButton = $('div.agent-feedback-widget-dismiss'); - this._dismissButton.appendChild(renderIcon(Codicon.close)); - this._dismissButton.title = nls.localize('dismiss', "Dismiss"); - this._headerNode.appendChild(this._dismissButton); - this._domNode.appendChild(this._headerNode); // Body (collapsible) — starts collapsed @@ -155,11 +135,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._toggleExpanded(); })); - // Dismiss button click - this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => { - e.stopPropagation(); - this._dismiss(); - })); } private _toggleExpanded(): void { @@ -170,27 +145,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid } } - private _dismiss(): void { - // Remove all feedback items in this widget from the service - for (const feedback of this._feedbackItems) { - this._agentFeedbackService.removeFeedback(this._sessionResource, feedback.id); - } - - this._domNode.classList.add('fadeOut'); - - const dispose = () => { - this.dispose(); - }; - - const handle = setTimeout(dispose, 150); - this._domNode.addEventListener('animationend', () => { - clearTimeout(handle); - dispose(); - }, { once: true }); - } - private _updateTitle(): void { - const count = this._feedbackItems.length; + const count = this._commentItems.length; if (count === 1) { this._titleNode.textContent = nls.localize('oneComment', "1 comment"); } else { @@ -213,37 +169,268 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid clearNode(this._bodyNode); this._itemElements.clear(); - for (const feedback of this._feedbackItems) { + for (const comment of this._commentItems) { const item = $('div.agent-feedback-widget-item'); - this._itemElements.set(feedback.id, item); + item.classList.add(`agent-feedback-widget-item-${comment.source}`); + if (comment.suggestion) { + item.classList.add('agent-feedback-widget-item-suggestion'); + } + this._itemElements.set(comment.id, item); + + const itemHeader = $('div.agent-feedback-widget-item-header'); + const itemMeta = $('div.agent-feedback-widget-item-meta'); - // Line indicator const lineInfo = $('span.agent-feedback-widget-line-info'); - if (feedback.range.startLineNumber === feedback.range.endLineNumber) { - lineInfo.textContent = nls.localize('lineNumber', "Line {0}", feedback.range.startLineNumber); + if (comment.range.startLineNumber === comment.range.endLineNumber) { + lineInfo.textContent = nls.localize('lineNumber', "Line {0}", comment.range.startLineNumber); } else { - lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", feedback.range.startLineNumber, feedback.range.endLineNumber); + lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", comment.range.startLineNumber, comment.range.endLineNumber); + } + itemMeta.appendChild(lineInfo); + + if (comment.source !== SessionEditorCommentSource.AgentFeedback) { + const typeBadge = $('span.agent-feedback-widget-item-type'); + typeBadge.textContent = this._getTypeLabel(comment); + itemMeta.appendChild(typeBadge); } - item.appendChild(lineInfo); - // Feedback text - const text = $('span.agent-feedback-widget-text'); - text.textContent = feedback.text; + itemHeader.appendChild(itemMeta); + + const actionBarContainer = $('div.agent-feedback-widget-item-actions'); + const actionBar = this._eventStore.add(new ActionBar(actionBarContainer)); + + const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined! }; + + // Edit action — only disabled for PR review comments + const isEditable = comment.source !== SessionEditorCommentSource.PRReview; + const editTooltip = isEditable + ? nls.localize('editComment', "Edit") + : nls.localize('editPRCommentDisabled', "PR review comments cannot be edited"); + itemActions.editAction = new Action( + 'agentFeedback.widget.edit', + editTooltip, + ThemeIcon.asClassName(Codicon.edit), + isEditable, + (): void => { this._startEditing(comment, text, itemActions); }, + ); + actionBar.push(itemActions.editAction, { icon: true, label: false }); + + if (comment.canConvertToAgentFeedback) { + itemActions.convertAction = new Action( + 'agentFeedback.widget.convert', + nls.localize('convertComment', "Convert to Agent Feedback"), + ThemeIcon.asClassName(Codicon.check), + true, + () => this._convertToAgentFeedback(comment), + ); + actionBar.push(itemActions.convertAction, { icon: true, label: false }); + } + itemActions.removeAction = new Action( + 'agentFeedback.widget.remove', + nls.localize('removeComment', "Remove"), + ThemeIcon.asClassName(Codicon.close), + true, + () => this._removeComment(comment), + ); + actionBar.push(itemActions.removeAction, { icon: true, label: false }); + + itemHeader.appendChild(actionBarContainer); + item.appendChild(itemHeader); + + const text = $('div.agent-feedback-widget-text'); + const rendered = this._markdownRendererService.render(new MarkdownString(comment.text)); + this._eventStore.add(rendered); + text.appendChild(rendered.element); item.appendChild(text); - // Hover handlers for range highlighting + if (comment.suggestion?.edits.length) { + item.appendChild(this._renderSuggestion(comment)); + } + this._eventStore.add(addDisposableListener(item, 'mouseenter', () => { - this._highlightRange(feedback); + this._highlightRange(comment); })); this._eventStore.add(addDisposableListener(item, 'mouseleave', () => { this._rangeHighlightDecoration.clear(); })); + this._eventStore.add(addDisposableListener(item, 'click', e => { + if ((e.target as HTMLElement | null)?.closest('.action-bar')) { + return; + } + this.focusFeedback(comment.id); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, comment.id); + this._revealComment(comment); + })); + this._bodyNode.appendChild(item); } } + private _getTypeLabel(comment: ISessionEditorComment): string { + if (comment.source === SessionEditorCommentSource.PRReview) { + return nls.localize('prReviewComment', "PR Review"); + } + + if (comment.source === SessionEditorCommentSource.CodeReview) { + return comment.suggestion + ? nls.localize('reviewSuggestion', "Review Suggestion") + : nls.localize('reviewComment', "Review"); + } + + return comment.suggestion + ? nls.localize('feedbackSuggestion', "Feedback Suggestion") + : nls.localize('feedbackComment', "Feedback"); + } + + private _renderSuggestion(comment: ISessionEditorComment): HTMLElement { + const suggestionNode = $('div.agent-feedback-widget-suggestion'); + const title = $('div.agent-feedback-widget-suggestion-title'); + title.textContent = nls.localize('suggestedChange', "Suggested Change"); + suggestionNode.appendChild(title); + + for (const edit of comment.suggestion?.edits ?? []) { + const editNode = $('div.agent-feedback-widget-suggestion-edit'); + const rangeLabel = $('div.agent-feedback-widget-suggestion-range'); + if (edit.range.startLineNumber === edit.range.endLineNumber) { + rangeLabel.textContent = nls.localize('suggestionLineNumber', "Line {0}", edit.range.startLineNumber); + } else { + rangeLabel.textContent = nls.localize('suggestionLineRange', "Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber); + } + editNode.appendChild(rangeLabel); + + const newText = $('pre.agent-feedback-widget-suggestion-text'); + newText.textContent = edit.newText; + editNode.appendChild(newText); + suggestionNode.appendChild(editNode); + } + + return suggestionNode; + } + + private _removeComment(comment: ISessionEditorComment): void { + if (comment.source === SessionEditorCommentSource.PRReview) { + this._codeReviewService.resolvePRReviewThread(this._sessionResource!, comment.sourceId); + return; + } + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + return; + } + + this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId); + } + + private _startEditing(comment: ISessionEditorComment, textContainer: HTMLElement, actions: ICommentItemActions): void { + if (comment.source === SessionEditorCommentSource.PRReview) { + return; + } + + // Disable all actions while editing + actions.editAction.enabled = false; + if (actions.convertAction) { + actions.convertAction.enabled = false; + } + actions.removeAction.enabled = false; + + const editStore = new DisposableStore(); + this._eventStore.add(editStore); + + clearNode(textContainer); + textContainer.classList.add('editing'); + + const textarea = $('textarea.agent-feedback-widget-edit-textarea') as HTMLTextAreaElement; + textarea.value = comment.text; + textarea.rows = 1; + textContainer.appendChild(textarea); + + // Auto-size the textarea + const autoSize = () => { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + this._editor.layoutOverlayWidget(this); + }; + autoSize(); + + editStore.add(addDisposableListener(textarea, 'input', autoSize)); + + editStore.add(addStandardDisposableListener(textarea, 'keydown', (e) => { + if (e.keyCode === KeyCode.Enter && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const newText = textarea.value.trim(); + if (newText) { + this._saveEdit(comment, newText); + } + // Widget will be rebuilt by the change event + } else if (e.keyCode === KeyCode.Escape) { + e.preventDefault(); + e.stopPropagation(); + this._stopEditing(comment, textContainer, editStore, actions); + } + })); + + // Stop editing when focus is lost + editStore.add(addDisposableListener(textarea, 'blur', () => { + this._stopEditing(comment, textContainer, editStore, actions); + })); + + textarea.focus(); + } + + private _saveEdit(comment: ISessionEditorComment, newText: string): void { + if (comment.source === SessionEditorCommentSource.AgentFeedback) { + this._agentFeedbackService.updateFeedback(this._sessionResource, comment.sourceId, newText); + } else if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.updateComment(this._sessionResource, comment.sourceId, newText); + } + } + + private _stopEditing(comment: ISessionEditorComment, textContainer: HTMLElement, editStore: DisposableStore, actions: ICommentItemActions): void { + editStore.dispose(); + + // Re-enable actions + actions.editAction.enabled = comment.source !== SessionEditorCommentSource.PRReview; + if (actions.convertAction) { + actions.convertAction.enabled = true; + } + actions.removeAction.enabled = true; + + textContainer.classList.remove('editing'); + clearNode(textContainer); + const rendered = this._markdownRendererService.render(new MarkdownString(comment.text)); + this._eventStore.add(rendered); + textContainer.appendChild(rendered.element); + this._editor.layoutOverlayWidget(this); + } + + private _convertToAgentFeedback(comment: ISessionEditorComment): void { + if (!comment.canConvertToAgentFeedback) { + return; + } + + const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview + ? comment.sourceId + : undefined; + + const feedback = this._agentFeedbackService.addFeedback( + this._sessionResource, + comment.resourceUri, + comment.range, + comment.text, + comment.suggestion, + createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), + sourcePRReviewCommentId, + ); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + } else if (comment.source === SessionEditorCommentSource.PRReview) { + this._codeReviewService.markPRReviewCommentConverted(this._sessionResource, comment.sourceId); + } + } + /** * Expand the widget body. */ @@ -277,7 +464,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid el.classList.remove('focused'); } - const feedback = this._feedbackItems.find(f => f.id === feedbackId); + const feedback = this._commentItems.find(f => f.id === feedbackId); if (!feedback) { return; } @@ -300,7 +487,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._rangeHighlightDecoration.clear(); } - private _highlightRange(feedback: IAgentFeedback): void { + private _highlightRange(feedback: ISessionEditorComment): void { const endLineNumber = feedback.range.endLineNumber; const range = new Range( feedback.range.startLineNumber, 1, @@ -333,7 +520,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid * Returns true if this widget contains the given feedback item (by id). */ containsFeedback(feedbackId: string): boolean { - return this._feedbackItems.some(f => f.id === feedbackId); + return this._commentItems.some(f => f.id === feedbackId); } /** @@ -351,11 +538,18 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const scrollTop = this._editor.getScrollTop(); const widgetWidth = getTotalWidth(this._domNode) || 280; + const widgetHeight = this._domNode.offsetHeight || 0; + const headerHeight = this._headerNode.offsetHeight || lineHeight; + + // Align the header center with the start line center before clamping within the editor content area. + const contentRelativeTop = this._editor.getTopForLineNumber(startLineNumber) + (lineHeight - headerHeight) / 2; + const scrollHeight = this._editor.getScrollHeight(); + const clampedContentTop = Math.min(Math.max(0, contentRelativeTop), Math.max(0, scrollHeight - widgetHeight)); this._position = { stackOrdinal: 2, preference: { - top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - lineHeight, + top: clampedContentTop - scrollTop, left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + widgetWidth) } }; @@ -368,8 +562,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid */ toggle(show: boolean): void { this._domNode.classList.toggle('visible', show); - if (show && this._feedbackItems.length > 0) { - this.layout(this._feedbackItems[0].range.startLineNumber); + if (show && this._commentItems.length > 0) { + this.layout(this._commentItems[0].range.startLineNumber); } } @@ -405,6 +599,16 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._editor.removeOverlayWidget(this); super.dispose(); } + + private _revealComment(comment: ISessionEditorComment): void { + const range = new Range( + comment.range.startLineNumber, + 1, + comment.range.endLineNumber, + this._editor.getModel()?.getLineMaxColumn(comment.range.endLineNumber) ?? 1, + ); + this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth); + } } /** @@ -423,26 +627,22 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito private readonly _editor: ICodeEditor, @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => { - if (this._sessionResource && e.sessionResource.toString() === this._sessionResource.toString()) { - this._rebuildWidgets(); - } - })); - this._store.add(this._agentFeedbackService.onDidChangeNavigation(sessionResource => { if (this._sessionResource && sessionResource.toString() === this._sessionResource.toString()) { this._handleNavigation(); } })); - this._store.add(this._editor.onDidChangeModel(() => { - this._resolveSession(); - this._rebuildWidgets(); - })); + const rebuildSignal = observableSignalFromEvent(this, Event.any( + this._agentFeedbackService.onDidChangeFeedback, + this._editor.onDidChangeModel, + )); this._store.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => { for (const widget of this._widgets) { @@ -450,8 +650,20 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito } })); - this._resolveSession(); - this._rebuildWidgets(); + this._store.add(autorun(reader => { + rebuildSignal.read(reader); + this._resolveSession(); + if (!this._sessionResource) { + this._clearWidgets(); + return; + } + + this._rebuildWidgets( + this._codeReviewService.getReviewState(this._sessionResource).read(reader), + this._codeReviewService.getPRReviewState(this._sessionResource).read(reader), + ); + this._handleNavigation(); + })); } private _resolveSession(): void { @@ -460,13 +672,16 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito this._sessionResource = undefined; return; } - this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); + this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService); } - private _rebuildWidgets(): void { + private _rebuildWidgets( + reviewState = this._sessionResource ? this._codeReviewService.getReviewState(this._sessionResource).get() : undefined, + prReviewState: IPRReviewState | undefined = this._sessionResource ? this._codeReviewService.getPRReviewState(this._sessionResource).get() : undefined, + ): void { this._clearWidgets(); - if (!this._sessionResource) { + if (!this._sessionResource || !reviewState) { return; } @@ -475,39 +690,109 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - // Filter to feedback items belonging to this editor's file - const fileFeedback = allFeedback.filter(f => f.resourceUri.toString() === model.uri.toString()); - if (fileFeedback.length === 0) { + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + reviewState, + prReviewState, + ); + const fileComments = this._getCommentsForModel(model.uri, comments); + if (fileComments.length === 0) { return; } - const groups = groupNearbyFeedback(fileFeedback, 5); + const groups = groupNearbySessionEditorComments(fileComments, 5); - for (const group of groups) { - const widget = new AgentFeedbackEditorWidget(this._editor, group, this._agentFeedbackService, this._sessionResource); + // Create widgets in reverse file order so that widgets further up in the + // file are added to the DOM last and therefore render on top of widgets + // further down. + for (let i = groups.length - 1; i >= 0; i--) { + const group = groups[i]; + const widget = this._instantiationService.createInstance(AgentFeedbackEditorWidget, this._editor, group, this._sessionResource); this._widgets.push(widget); widget.layout(group[0].range.startLineNumber); } } + private _getCommentsForModel(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const change = this._getSessionChangeForResource(resourceUri); + if (!change) { + return comments.filter(comment => isEqual(comment.resourceUri, resourceUri)); + } + + if (!this._isCurrentOrModifiedResource(change, resourceUri)) { + return []; + } + + return comments.filter(comment => comment.resourceUri.fsPath === resourceUri.fsPath); + } + + private _getSessionChangeForResource(resourceUri: URI): IChatSessionFileChange | IChatSessionFileChange2 | undefined { + if (!this._sessionResource) { + return undefined; + } + + const changes = this._sessionsManagementService.getSession(this._sessionResource)?.changes.get(); + if (!changes) { + return undefined; + } + + return changes.find(change => this._changeMatchesFsPath(change, resourceUri)); + } + + private _changeMatchesFsPath(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; + } + + private _isCurrentOrModifiedResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return isEqual(change.uri, resourceUri) || (change.modifiedUri ? isEqual(change.modifiedUri, resourceUri) : false); + } + + return isEqual(change.modifiedUri, resourceUri); + } + private _handleNavigation(): void { if (!this._sessionResource) { return; } - const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource); + const model = this._editor.getModel(); + if (!model) { + return; + } + + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + this._codeReviewService.getReviewState(this._sessionResource).get(), + this._codeReviewService.getPRReviewState(this._sessionResource).get(), + ); + const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource, comments); if (bearing.activeIdx < 0) { return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - const activeFeedback = allFeedback[bearing.activeIdx]; + const activeFeedback = comments[bearing.activeIdx]; if (!activeFeedback) { return; } + if (this._getCommentsForModel(model.uri, [activeFeedback]).length === 0) { + for (const widget of this._widgets) { + widget.collapse(); + } + return; + } + // Expand the widget containing the active feedback, collapse all others for (const widget of this._widgets) { if (widget.containsFeedback(activeFeedback.id)) { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts index d02c9725cd1b8..027e8ca539935 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts @@ -11,11 +11,12 @@ import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IObjectTreeElement, ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js'; import { Action } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { basename } from '../../../../base/common/path.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IRange } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { localize } from '../../../../nls.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -41,7 +42,8 @@ interface IFeedbackCommentElement { readonly id: string; readonly text: string; readonly resourceUri: URI; - readonly range: IRange; + readonly codeSelection?: string; + readonly diffHunks?: string; } type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement; @@ -78,7 +80,7 @@ class FeedbackFileRenderer implements ITreeRenderer { - for (const item of element.items) { - this._agentFeedbackService.removeFeedback(this._sessionResource, item.id); + if (this._agentFeedbackService) { + const service = this._agentFeedbackService; + const sessionResource = this._sessionResource; + templateData.actionBar.push(new Action( + 'agentFeedback.removeFileComments', + localize('agentFeedbackHover.removeAll', "Remove All"), + ThemeIcon.asClassName(Codicon.close), + true, + () => { + for (const item of element.items) { + service.removeFeedback(sessionResource, item.id); + } } - } - ), { icon: true, label: false }); + ), { icon: true, label: false }); + } } disposeTemplate(templateData: IFeedbackFileTemplate): void { @@ -129,8 +135,10 @@ class FeedbackFileRenderer implements ITreeRenderer; element: IFeedbackCommentElement | undefined; } @@ -139,8 +147,10 @@ class FeedbackCommentRenderer implements ITreeRenderer { - const data = templateData.element; - if (data) { - e.preventDefault(); - e.stopPropagation(); - this._agentFeedbackService.revealFeedback(this._sessionResource, data.id); - } - })); + const templateData: IFeedbackCommentTemplate = { textElement, row, actionBar, templateDisposables, hoverDisposable, element: undefined }; + + if (this._agentFeedbackService) { + const service = this._agentFeedbackService; + const sessionResource = this._sessionResource; + templateDisposables.add(dom.addDisposableListener(row, dom.EventType.CLICK, (e) => { + const data = templateData.element; + if (data) { + e.preventDefault(); + e.stopPropagation(); + service.revealFeedback(sessionResource, data.id); + } + })); + } return templateData; } @@ -173,21 +189,58 @@ class FeedbackCommentRenderer implements ITreeRenderer this._buildCommentHover(element), + { groupId: 'agent-feedback-comment' } + ); + } + templateData.actionBar.clear(); - templateData.actionBar.push(new Action( - 'agentFeedback.removeComment', - localize('agentFeedbackHover.remove', "Remove"), - ThemeIcon.asClassName(Codicon.close), - true, - () => { - this._agentFeedbackService.removeFeedback(this._sessionResource, element.id); - } - ), { icon: true, label: false }); + if (this._agentFeedbackService) { + const service = this._agentFeedbackService; + const sessionResource = this._sessionResource; + templateData.actionBar.push(new Action( + 'agentFeedback.removeComment', + localize('agentFeedbackHover.remove', "Remove"), + ThemeIcon.asClassName(Codicon.close), + true, + () => { + service.removeFeedback(sessionResource, element.id); + } + ), { icon: true, label: false }); + } } disposeTemplate(templateData: IFeedbackCommentTemplate): void { templateData.templateDisposables.dispose(); } + + private _buildCommentHover(element: IFeedbackCommentElement): IDelayedHoverOptions { + const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + markdown.appendText(element.text); + + if (element.codeSelection) { + const languageId = this._languageService.guessLanguageIdByFilepathOrFirstLine(element.resourceUri); + markdown.appendMarkdown('\n\n'); + markdown.appendCodeblock(languageId ?? '', element.codeSelection); + } + + if (element.diffHunks) { + markdown.appendMarkdown('\n\n'); + markdown.appendCodeblock('diff', element.diffHunks); + } + + return { + content: markdown, + style: HoverStyle.Pointer, + position: { + hoverPosition: HoverPosition.RIGHT, + }, + }; + } } // --- Hover --- @@ -202,16 +255,18 @@ export class AgentFeedbackHover extends Disposable { constructor( private readonly _element: HTMLElement, private readonly _attachment: IAgentFeedbackVariableEntry, + private readonly _canDelete: boolean, @IHoverService private readonly _hoverService: IHoverService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @ILanguageService private readonly _languageService: ILanguageService, ) { super(); // Show on hover (delayed) this._store.add(this._hoverService.setupDelayedHover( this._element, - () => this._store.add(this._buildHoverContent()), // needs a better disposable story + () => this._store.add(this._buildHoverContent()), { groupId: 'chat-attachments' } )); @@ -252,8 +307,8 @@ export class AgentFeedbackHover extends Disposable { treeContainer, new FeedbackTreeDelegate(), [ - new FeedbackFileRenderer(resourceLabels, this._agentFeedbackService, this._attachment.sessionResource), - new FeedbackCommentRenderer(this._agentFeedbackService, this._attachment.sessionResource), + new FeedbackFileRenderer(resourceLabels, this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource), + new FeedbackCommentRenderer(this._canDelete ? this._agentFeedbackService : undefined, this._attachment.sessionResource, this._hoverService, this._languageService), ], { defaultIndent: 0, @@ -302,6 +357,7 @@ export class AgentFeedbackHover extends Disposable { return { content: hoverElement, style: HoverStyle.Pointer, + persistence: { hideOnHover: false }, position: { hoverPosition: HoverPosition.ABOVE }, trapFocus: true, appearance: { compact: true }, @@ -313,6 +369,7 @@ export class AgentFeedbackHover extends Disposable { private _buildTreeData(): { children: IObjectTreeElement[]; commentElements: IFeedbackCommentElement[] } { // Group feedback items by file const byFile = new Map(); + for (const item of this._attachment.feedbackItems) { const key = item.resourceUri.toString(); let group = byFile.get(key); @@ -325,7 +382,8 @@ export class AgentFeedbackHover extends Disposable { id: item.id, text: item.text, resourceUri: item.resourceUri, - range: item.range, + codeSelection: item.codeSelection, + diffHunks: item.diffHunks, }); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts deleted file mode 100644 index 119bbad2fc0ae..0000000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackLineDecorationContribution.ts +++ /dev/null @@ -1,169 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/agentFeedbackLineDecoration.css'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; -import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; -import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; -import { TrackedRangeStickiness } from '../../../../editor/common/model.js'; -import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { getSessionForResource } from './agentFeedbackEditorUtils.js'; -import { Selection } from '../../../../editor/common/core/selection.js'; - -const addFeedbackHintDecoration = ModelDecorationOptions.register({ - description: 'agent-feedback-add-hint', - linesDecorationsClassName: `${ThemeIcon.asClassName(Codicon.add)} agent-feedback-add-hint`, - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, -}); - -export class AgentFeedbackLineDecorationContribution extends Disposable implements IEditorContribution { - - static readonly ID = 'agentFeedback.lineDecorationContribution'; - - private _hintDecorationId: string | null = null; - private _hintLine = -1; - private _sessionResource: URI | undefined; - private _feedbackLines = new Set(); - - constructor( - private readonly _editor: ICodeEditor, - @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - ) { - super(); - - this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackLines())); - this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged())); - this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e))); - this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1))); - this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e))); - - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _onModelChanged(): void { - this._updateHintDecoration(-1); - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _resolveSession(): void { - const model = this._editor.getModel(); - if (!model) { - this._sessionResource = undefined; - return; - } - this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); - } - - private _updateFeedbackLines(): void { - if (!this._sessionResource) { - this._feedbackLines.clear(); - return; - } - - const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource); - const lines = new Set(); - - for (const item of feedbackItems) { - const model = this._editor.getModel(); - if (!model || item.resourceUri.toString() !== model.uri.toString()) { - continue; - } - - lines.add(item.range.startLineNumber); - } - - this._feedbackLines = lines; - } - - private _onMouseMove(e: IEditorMouseEvent): void { - if (!this._sessionResource) { - this._updateHintDecoration(-1); - return; - } - - const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; - const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; - if (e.target.position - && (isLineDecoration || isContentArea) - && !this._feedbackLines.has(e.target.position.lineNumber) - ) { - this._updateHintDecoration(e.target.position.lineNumber); - } else { - this._updateHintDecoration(-1); - } - } - - private _updateHintDecoration(line: number): void { - if (line === this._hintLine) { - return; - } - - this._hintLine = line; - this._editor.changeDecorations(accessor => { - if (this._hintDecorationId) { - accessor.removeDecoration(this._hintDecorationId); - this._hintDecorationId = null; - } - if (line !== -1) { - this._hintDecorationId = accessor.addDecoration( - new Range(line, 1, line, 1), - addFeedbackHintDecoration, - ); - } - }); - } - - private _onMouseDown(e: IEditorMouseEvent): void { - if (!e.target.position - || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS - || e.target.detail.isAfterLines - || !this._sessionResource - ) { - return; - } - - const lineNumber = e.target.position.lineNumber; - - // Lines with existing feedback - do nothing - if (this._feedbackLines.has(lineNumber)) { - return; - } - - // Select the line content and focus the editor - const model = this._editor.getModel(); - if (!model) { - return; - } - - const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); - const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); - if (startColumn === 0 || endColumn === 0) { - // Empty line - select the whole line range - this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); - } else { - this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); - } - this._editor.focus(); - } - - override dispose(): void { - this._updateHintDecoration(-1); - super.dispose(); - } -} - -registerEditorContribution(AgentFeedbackLineDecorationContribution.ID, AgentFeedbackLineDecorationContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackOverviewRulerContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackOverviewRulerContribution.ts index ecbca6154b3df..77cb16fcac70d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackOverviewRulerContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackOverviewRulerContribution.ts @@ -15,8 +15,8 @@ import { localize } from '../../../../nls.js'; import { URI } from '../../../../base/common/uri.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { getSessionForResource } from './agentFeedbackEditorUtils.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; const overviewRulerAgentFeedbackForeground = registerColor( 'editorOverviewRuler.agentFeedbackForeground', @@ -35,7 +35,7 @@ export class AgentFeedbackOverviewRulerContribution extends Disposable implement private readonly _editor: ICodeEditor, @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); @@ -57,7 +57,7 @@ export class AgentFeedbackOverviewRulerContribution extends Disposable implement this._sessionResource = undefined; return; } - this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); + this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._sessionsManagementService); } private _updateDecorations(): void { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 03c6e09a1757c..cbd0f3beca843 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -11,9 +11,15 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { generateUuid } from '../../../../base/common/uuid.js'; import { isEqual } from '../../../../base/common/resources.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; +import { changeMatchesResource, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; // --- Types -------------------------------------------------------------------- @@ -23,6 +29,15 @@ export interface IAgentFeedback { readonly resourceUri: URI; readonly range: IRange; readonly sessionResource: URI; + readonly suggestion?: ICodeReviewSuggestion; + readonly codeSelection?: string; + readonly diffHunks?: string; + /** When this feedback was converted from a PR review comment, the original thread ID. */ + readonly sourcePRReviewCommentId?: string; +} + +export interface INavigableSessionComment { + readonly id: string; } export interface IAgentFeedbackChangeEvent { @@ -48,13 +63,18 @@ export interface IAgentFeedbackService { /** * Add a feedback item for the given session. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback; /** * Remove a single feedback item. */ removeFeedback(sessionResource: URI, feedbackId: string): void; + /** + * Update the text of an existing feedback item. + */ + updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void; + /** * Get all feedback items for a session. */ @@ -70,20 +90,34 @@ export interface IAgentFeedbackService { */ revealFeedback(sessionResource: URI, feedbackId: string): Promise; + /** + * Open an editor for the given session comment (feedback or code-review) at its range + * and set it as the navigation anchor. + */ + revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise; + /** * Navigate to next/previous feedback item in a session. */ getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined; + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined; + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void; /** * Get the current navigation bearings for a session. */ - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing; + getNavigationBearing(sessionResource: URI, items?: readonly INavigableSessionComment[]): IAgentFeedbackNavigationBearing; /** * Clear all feedback items for a session (e.g., after sending). */ clearFeedback(sessionResource: URI): void; + + /** + * Add a feedback item and then submit the feedback. Waits for the + * attachment to be updated in the chat widget before submitting. + */ + addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise; } // --- Implementation ----------------------------------------------------------- @@ -105,13 +139,16 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe constructor( @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IEditorService private readonly _editorService: IEditorService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, ) { super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -125,6 +162,10 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe resourceUri, range, sessionResource, + suggestion, + codeSelection: context?.codeSelection, + diffHunks: context?.diffHunks, + sourcePRReviewCommentId, }; // Insert at the correct sorted position. @@ -187,6 +228,25 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } + updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + return; + } + + const idx = feedbackItems.findIndex(f => f.id === feedbackId); + if (idx >= 0) { + const existing = feedbackItems[idx]; + feedbackItems[idx] = { + ...existing, + text: newText, + }; + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + } + } + getFeedback(sessionResource: URI): readonly IAgentFeedback[] { return this._feedbackBySession.get(sessionResource.toString()) ?? []; } @@ -230,14 +290,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } - for (const session of this._agentSessionsService.model.sessions) { - if (!isEqual(session.resource, sessionResource)) { - continue; - } + const session = this._sessionsManagementService.getSession(sessionResource); + if (!session) { + return false; + } - if (agentSessionContainsResource(session, resourceUri)) { - return true; - } + const changes = session.changes.get(); + if (changes.some(change => changeMatchesResource(change, resourceUri))) { + return true; } return false; @@ -250,50 +310,132 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!feedback) { return; } - await this._editorService.openEditor({ - resource: feedback.resourceUri, - options: { - preserveFocus: false, - revealIfVisible: true, - } - }); - setTimeout(() => { - this._navigationAnchorBySession.set(key, feedbackId); - this._onDidChangeNavigation.fire(sessionResource); - }, 50); // delay to ensure editor has revealed the correct position before firing navigation event + await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range); + } + + async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise { + const selection = { startLineNumber: range.startLineNumber, startColumn: range.startColumn }; + const sessionData = this._sessionsManagementService.getSession(sessionResource); + const sessionChange = this._getSessionChange(resourceUri, sessionData?.changes.get()); + + if (sessionChange?.isDeletion && sessionChange.originalUri) { + await this._editorService.openEditor({ + resource: sessionChange.originalUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }); + } else if (sessionChange?.originalUri) { + await this._editorService.openEditor({ + original: { resource: sessionChange.originalUri }, + modified: { resource: sessionChange.modifiedUri }, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }); + } else { + await this._editorService.openEditor({ + resource: sessionChange?.modifiedUri ?? resourceUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }); + } + + this.setNavigationAnchor(sessionResource, commentId); + } + + private _getSessionChange(resourceUri: URI, changes: readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[] | { + readonly files: number; + readonly insertions: number; + readonly deletions: number; + } | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined { + if (!(changes instanceof Array)) { + return undefined; + } + + const matchingChange = changes.find(change => this._changeContainsResource(change, resourceUri)); + if (!matchingChange) { + return undefined; + } + + if (isIChatSessionFileChange2(matchingChange)) { + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri ?? matchingChange.uri, + isDeletion: matchingChange.modifiedUri === undefined, + }; + } + + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri, + isDeletion: false, + }; + } + + private _changeContainsResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; } getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next); + } + + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key); - if (!feedbackItems?.length) { + if (!items.length) { this._navigationAnchorBySession.delete(key); return undefined; } const anchorId = this._navigationAnchorBySession.get(key); - let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1; if (anchorIndex < 0 && !next) { anchorIndex = 0; } const nextIndex = next - ? (anchorIndex + 1) % feedbackItems.length - : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + ? (anchorIndex + 1) % items.length + : (anchorIndex - 1 + items.length) % items.length; - const feedback = feedbackItems[nextIndex]; - this._navigationAnchorBySession.set(key, feedback.id); + const item = items[nextIndex]; + this.setNavigationAnchor(sessionResource, item.id); + return item; + } + + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void { + const key = sessionResource.toString(); + if (itemId) { + this._navigationAnchorBySession.set(key, itemId); + } else { + this._navigationAnchorBySession.delete(key); + } this._onDidChangeNavigation.fire(sessionResource); - return feedback; } - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key) ?? []; const anchorId = this._navigationAnchorBySession.get(key); - const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; - return { activeIdx, totalCount: feedbackItems.length }; + const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: items.length }; } clearFeedback(sessionResource: URI): void { @@ -304,4 +446,30 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeNavigation.fire(sessionResource); this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); } + + async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId); + + // Wait for the attachment contribution to update the chat widget's attachment model + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (widget) { + const attachmentId = 'agentFeedback:' + sessionResource.toString(); + const hasAttachment = () => widget.attachmentModel.attachments.some(a => a.id === attachmentId); + + if (!hasAttachment()) { + await Event.toPromise( + Event.filter(widget.attachmentModel.onDidChange, () => hasAttachment()) + ); + } + } else { + this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString()); + await new Promise(resolve => setTimeout(resolve, 100)); + } + + try { + await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + } catch (err) { + this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err); + } + } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index f8d62a4fe28e7..b467ff7f7aa8d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -7,10 +7,13 @@ position: absolute; z-index: 10000; background-color: var(--vscode-panel-background); - border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + border: 1px solid var(--vscode-agentFeedbackInputWidget-border, var(--vscode-input-border, var(--vscode-widget-border))); + box-shadow: var(--vscode-shadow-lg); border-radius: 8px; padding: 4px; + display: flex; + flex-direction: row; + align-items: flex-end; } .agent-feedback-input-widget textarea { @@ -18,7 +21,7 @@ border: none; color: var(--vscode-input-foreground); border-radius: 4px; - padding: 0; + padding: 0 0 0 6px; outline: none; min-width: 150px; max-width: 400px; @@ -28,6 +31,7 @@ word-wrap: break-word; box-sizing: border-box; display: block; + flex: 1; } .agent-feedback-input-widget textarea:focus { @@ -46,3 +50,15 @@ overflow: hidden; white-space: pre; } + +.agent-feedback-input-widget .agent-feedback-input-actions { + display: flex; + align-items: center; + margin-left: 2px; + flex-shrink: 0; +} + +.agent-feedback-input-widget .agent-feedback-input-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css index 1acdbe228ce56..766e481b9eb51 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css @@ -8,13 +8,13 @@ color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); border-radius: 6px; - border: 1px solid var(--vscode-contrastBorder); + border: 1px solid var(--vscode-editorHoverWidget-border); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 3ca674d2cf444..ef551fd08700e 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -11,7 +11,7 @@ background-color: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); border-radius: 8px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); font-size: 12px; line-height: 1.4; opacity: 0; @@ -36,7 +36,7 @@ .agent-feedback-widget-arrow { position: absolute; left: -8px; - top: 12px; + top: 9px; width: 0; height: 0; border-top: 8px solid transparent; @@ -113,24 +113,6 @@ } /* Dismiss button */ -.agent-feedback-widget-dismiss { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 4px; - cursor: pointer; - color: var(--vscode-foreground); - opacity: 0.7; - transition: opacity 0.1s; -} - -.agent-feedback-widget-dismiss:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Body - collapsible */ .agent-feedback-widget-body { transition: max-height 0.2s ease-in-out, padding 0.2s ease-in-out; @@ -152,6 +134,7 @@ border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); cursor: pointer; position: relative; + gap: 6px; } .agent-feedback-widget-item:last-child { @@ -167,12 +150,70 @@ color: var(--vscode-list-activeSelectionForeground); } +.agent-feedback-widget-item-codeReview { + box-shadow: inset 2px 0 0 var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview { + box-shadow: inset 2px 0 0 var(--vscode-editorInfo-foreground); +} + +.agent-feedback-widget-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.agent-feedback-widget-item-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.agent-feedback-widget-item-actions { + margin-left: auto; + flex: 0 0 auto; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.agent-feedback-widget-item:hover .agent-feedback-widget-item-actions { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.agent-feedback-widget-item-type { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.2px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 22%, transparent); + color: var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 22%, transparent); + color: var(--vscode-editorInfo-foreground); +} + /* Line info */ .agent-feedback-widget-line-info { font-size: 10px; font-weight: 600; color: var(--vscode-descriptionForeground); - margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } @@ -183,9 +224,90 @@ word-wrap: break-word; } +.agent-feedback-widget-text .rendered-markdown p { + margin: 0; +} + +.agent-feedback-widget-text .rendered-markdown code { + font-family: var(--monaco-monospace-font); + font-size: 11px; + padding: 1px 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); +} + +.agent-feedback-widget-suggestion { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 12%, transparent); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 10%, transparent); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 10%, transparent); +} + +.agent-feedback-widget-suggestion-title, +.agent-feedback-widget-suggestion-range { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-suggestion-edit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-feedback-widget-suggestion-text { + margin: 0; + padding: 6px 8px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; + font-family: monospace; + font-size: 11px; + line-height: 1.45; + background: color-mix(in srgb, var(--vscode-editor-background) 65%, transparent); +} + /* Gutter decoration for range indicator on hover */ .agent-feedback-widget-range-glyph { margin-left: 8px; z-index: 5; border-left: 2px solid var(--vscode-editorGutter-modifiedBackground); } + +/* Inline edit textarea */ +.agent-feedback-widget-text.editing { + padding: 0; +} + +.agent-feedback-widget-edit-textarea { + width: 100%; + min-height: 22px; + padding: 4px 6px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + line-height: 1.4; + resize: none; + overflow: hidden; + box-sizing: border-box; +} + +.agent-feedback-widget-edit-textarea:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css deleted file mode 100644 index 6f503b0143fbb..0000000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-editor .agent-feedback-line-decoration, -.monaco-editor .agent-feedback-add-hint { - border-radius: 3px; - display: flex !important; - align-items: center; - justify-content: center; - background-color: var(--vscode-editorHoverWidget-background); - cursor: pointer; - border: 1px solid var(--vscode-editorHoverWidget-border); - box-sizing: border-box; -} - -.monaco-editor .agent-feedback-line-decoration:hover, -.monaco-editor .agent-feedback-add-hint:hover { - background-color: var(--vscode-editorHoverWidget-border); -} - -.monaco-editor .agent-feedback-add-hint { - opacity: 0.7; -} - -.monaco-editor .agent-feedback-add-hint:hover { - opacity: 1; -} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts new file mode 100644 index 0000000000000..ef756423d42d2 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedback } from './agentFeedbackService.js'; +import { CodeReviewStateKind, ICodeReviewComment, ICodeReviewState, ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; + +export const enum SessionEditorCommentSource { + AgentFeedback = 'agentFeedback', + CodeReview = 'codeReview', + PRReview = 'prReview', +} + +export interface ISessionEditorComment { + readonly id: string; + readonly sourceId: string; + readonly source: SessionEditorCommentSource; + readonly sessionResource: URI; + readonly resourceUri: URI; + readonly range: IRange; + readonly text: string; + readonly suggestion?: ICodeReviewSuggestion; + readonly severity?: string; + readonly canConvertToAgentFeedback: boolean; +} + +export function getCodeReviewComments(reviewState: ICodeReviewState): readonly ICodeReviewComment[] { + return reviewState.kind === CodeReviewStateKind.Result ? reviewState.comments : []; +} + +export function getPRReviewComments(prReviewState: IPRReviewState | undefined): readonly IPRReviewComment[] { + return prReviewState?.kind === PRReviewStateKind.Loaded ? prReviewState.comments : []; +} + +export function getSessionEditorComments( + sessionResource: URI, + agentFeedbackItems: readonly IAgentFeedback[], + reviewState: ICodeReviewState, + prReviewState?: IPRReviewState, +): readonly ISessionEditorComment[] { + const comments: ISessionEditorComment[] = []; + + for (const item of agentFeedbackItems) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: item.resourceUri, + range: item.range, + text: item.text, + suggestion: item.suggestion, + canConvertToAgentFeedback: false, + }); + } + + for (const item of getCodeReviewComments(reviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.CodeReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.CodeReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + suggestion: item.suggestion, + severity: item.severity, + canConvertToAgentFeedback: true, + }); + } + + for (const item of getPRReviewComments(prReviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.PRReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.PRReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + canConvertToAgentFeedback: true, + }); + } + + comments.sort(compareSessionEditorComments); + return comments; +} + +export function compareSessionEditorComments(a: ISessionEditorComment, b: ISessionEditorComment): number { + return a.resourceUri.toString().localeCompare(b.resourceUri.toString()) + || Range.compareRangesUsingStarts(Range.lift(a.range), Range.lift(b.range)) + || a.source.localeCompare(b.source) + || a.sourceId.localeCompare(b.sourceId); +} + +export function groupNearbySessionEditorComments(items: readonly ISessionEditorComment[], lineThreshold: number = 5): ISessionEditorComment[][] { + if (items.length === 0) { + return []; + } + + const sorted = [...items].sort(compareSessionEditorComments); + const groups: ISessionEditorComment[][] = []; + let currentGroup: ISessionEditorComment[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const firstItem = currentGroup[0]; + const currentItem = sorted[i]; + + const sameResource = currentItem.resourceUri.toString() === firstItem.resourceUri.toString(); + const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; + + if (sameResource && verticalSpan <= lineThreshold) { + currentGroup.push(currentItem); + } else { + groups.push(currentGroup); + currentGroup = [currentItem]; + } + } + + groups.push(currentGroup); + return groups; +} + +export function getResourceEditorComments(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const resource = resourceUri.toString(); + return comments.filter(comment => comment.resourceUri.toString() === resource); +} + +export function toSessionEditorCommentId(source: SessionEditorCommentSource, sourceId: string): string { + return `${source}:${sourceId}`; +} + +export function hasAgentFeedbackComments(comments: readonly ISessionEditorComment[]): boolean { + return comments.some(comment => comment.source === SessionEditorCommentSource.AgentFeedback); +} diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts new file mode 100644 index 0000000000000..2314f52bc38b5 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { toAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AgentFeedbackOverlayWidget } from '../../browser/agentFeedbackEditorOverlay.js'; +import { clearAllFeedbackActionId, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from '../../browser/agentFeedbackEditorActions.js'; + +interface INavigationBearings { + readonly activeIdx: number; + readonly totalCount: number; +} + +interface IFixtureOptions { + readonly navigationBearings: INavigationBearings; + readonly hasAgentFeedbackActions?: boolean; +} + +class FixtureMenuService implements IMenuService { + constructor(private readonly _hasAgentFeedbackActions: boolean) { + } + + declare readonly _serviceBrand: undefined; + + createMenu(_id: MenuId): IMenu { + const navigateActions = [ + toAction({ id: navigationBearingFakeActionId, label: 'Navigation Status', run: () => { } }), + toAction({ id: navigatePreviousFeedbackActionId, label: 'Previous', class: 'codicon codicon-arrow-up', run: () => { } }), + toAction({ id: navigateNextFeedbackActionId, label: 'Next', class: 'codicon codicon-arrow-down', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[]; + + const submitActions = this._hasAgentFeedbackActions + ? [ + toAction({ id: submitFeedbackActionId, label: 'Submit', class: 'codicon codicon-send', run: () => { } }), + toAction({ id: clearAllFeedbackActionId, label: 'Clear', class: 'codicon codicon-clear-all', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[] + : []; + + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => submitActions.length > 0 + ? [ + ['navigate', navigateActions], + ['a_submit', submitActions], + ] + : [ + ['navigate', navigateActions], + ], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.classList.add('monaco-workbench'); + context.container.style.width = '420px'; + context.container.style.height = '64px'; + context.container.style.padding = '12px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IMenuService, new FixtureMenuService(options.hasAgentFeedbackActions ?? true)); + registerWorkbenchServices(reg); + }, + }); + + const widget = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackOverlayWidget)); + widget.show(options.navigationBearings); + context.container.appendChild(widget.getDomNode()); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + ZeroOfZero: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 0 }, + hasAgentFeedbackActions: false, + }), + }), + + SingleFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 1 }, + }), + }), + + FirstOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 3 }, + }), + }), + + ReviewOnlyTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 2 }, + hasAgentFeedbackActions: false, + }), + }), + + MiddleOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 1, totalCount: 3 }, + }), + }), + + MixedFourComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 4 }, + hasAgentFeedbackActions: true, + }), + }), + + LastOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 3 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts new file mode 100644 index 0000000000000..3beeee9243db4 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -0,0 +1,413 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Color } from '../../../../../base/common/color.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { CodeReviewStateKind, ICodeReviewService, ICodeReviewState, ICodeReviewSuggestion, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; +import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', + '', + 'function gamma() {', + '\tconst done = true;', + '\treturn done;', + '}', +].join('\n'); + +interface IFixtureOptions { + readonly expanded?: boolean; + readonly focusedCommentId?: string; + readonly hidden?: boolean; + readonly commentItems: readonly ISessionEditorComment[]; +} + +function createRange(startLineNumber: number, endLineNumber: number = startLineNumber): IRange { + return { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; +} + +function createFeedbackComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + return { + id: `agentFeedback:${id}`, + sourceId: id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + text, + suggestion, + canConvertToAgentFeedback: false, + }; +} + +function createReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + const range: IRange = { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; + + return { + id: `codeReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.CodeReview, + text, + resourceUri: fileResource, + range, + sessionResource, + suggestion, + severity: 'warning', + canConvertToAgentFeedback: true, + }; +} + +function createPRReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber): ISessionEditorComment { + return { + id: `prReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.PRReview, + text, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + sessionResource, + canConvertToAgentFeedback: true, + }; +} + +function createMockAgentFeedbackService(): IAgentFeedbackService { + return new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override addFeedback(): IAgentFeedback { + throw new Error('Not implemented for fixture'); + } + + override removeFeedback(): void { } + + override getFeedback(): readonly IAgentFeedback[] { + return []; + } + + override getMostRecentSessionForResource(): URI | undefined { + return undefined; + } + + override async revealFeedback(): Promise { } + + override getNextFeedback(): IAgentFeedback | undefined { + return undefined; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: 0 }; + } + + override getNextNavigableItem() { + return undefined; + } + + override setNavigationAnchor(): void { } + + override clearFeedback(): void { } + + override async addFeedbackAndSubmit(): Promise { } + }(); +} + +function createMockCodeReviewService(): ICodeReviewService { + return new class extends mock() { + private readonly _state = observableValue('fixture.reviewState', { kind: CodeReviewStateKind.Idle }); + + override getReviewState() { + return this._state; + } + + override hasReview(): boolean { + return false; + } + + override requestReview(): void { } + + override removeComment(): void { } + + override dismissReview(): void { } + + private readonly _prState = observableValue('fixture.prReviewState', { kind: PRReviewStateKind.None }); + + override getPRReviewState() { + return this._prState; + } + + override async resolvePRReviewThread(): Promise { } + }(); +} + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + + const colorMap = [ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#4fc1ff'), + Color.fromHex('#c586c0'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + Color.fromHex('#f44747'), + ]; + + TokenizationRegistry.setColorMap(colorMap); +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const agentFeedbackService = createMockAgentFeedbackService(); + const codeReviewService = createMockCodeReviewService(); + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + + const editorOptions: ICodeEditorWidgetOptions = { + contributions: [], + }; + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + editorOptions + )); + + editor.setModel(model); + + const widget = scopedDisposables.add(instantiationService.createInstance( + AgentFeedbackEditorWidget, + editor, + options.commentItems, + sessionResource, + )); + + widget.layout(options.commentItems[0].range.startLineNumber); + + if (options.expanded) { + widget.expand(); + } + + if (options.focusedCommentId) { + widget.focusFeedback(options.focusedCommentId); + } + + if (options.hidden) { + const domNode = widget.getDomNode(); + domNode.style.transition = 'none'; + domNode.style.animation = 'none'; + widget.toggle(false); + } +} + +const singleFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), +]; + +const groupedFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createFeedbackComment('f-2', 'This return statement can be simplified.', 3), + createFeedbackComment('f-3', 'Consider documenting why this branch is needed.', 6, 8), +]; + +const reviewOnly = [ + createReviewComment('r-1', 'Handle the null case before returning here.', 7), + createReviewComment('r-2', 'This branch needs a stronger explanation.', 8), +]; + +const mixedComments = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createReviewComment('r-1', 'This should be extracted into a helper.', 3), + createFeedbackComment('f-2', 'Consider renaming this for readability.', 4), +]; + +const reviewSuggestion: ICodeReviewSuggestion = { + edits: [ + { range: createRange(8), oldText: '\tconst third = second + 1;', newText: '\tconst third = second + computeOffset();' }, + ], +}; + +const suggestionMix = [ + createReviewComment('r-3', 'Prefer using the helper so the intent is explicit.', 8, 8, reviewSuggestion), + createFeedbackComment('f-3', 'Keep the helper name aligned with the domain concept.', 9), +]; + +const prReviewOnly = [ + createPRReviewComment('pr-1', 'This variable should be renamed to match our naming conventions.', 2), + createPRReviewComment('pr-2', 'Please add error handling for the edge case when second is zero.', 7, 8), +]; + +const allSourcesMixed = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createPRReviewComment('pr-1', 'Our style guide says to use descriptive names here.', 3), + createReviewComment('r-1', 'This should be extracted into a helper.', 6), + createPRReviewComment('pr-2', 'This logic duplicates what we have in utils.ts — consider reusing.', 8, 9), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + CollapsedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + }), + }), + + ExpandedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + expanded: true, + }), + }), + + CollapsedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + }), + }), + + ExpandedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + }), + }), + + ExpandedFocusedFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + focusedCommentId: 'agentFeedback:f-2', + }), + }), + + ExpandedReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: reviewOnly, + expanded: true, + }), + }), + + ExpandedMixedComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + }), + }), + + ExpandedFocusedReviewComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + focusedCommentId: 'codeReview:r-1', + }), + }), + + ExpandedReviewSuggestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: suggestionMix, + expanded: true, + }), + }), + + ExpandedPRReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: prReviewOnly, + expanded: true, + }), + }), + + ExpandedAllSourcesMixed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + }), + }), + + ExpandedFocusedPRReview: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + focusedCommentId: 'prReview:pr-2', + }), + }), + + HiddenWidget: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + hidden: true, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 7818c7e172d09..de89dd854bcf8 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -197,4 +197,14 @@ suite('AgentFeedbackService - Ordering', () => { assert.strictEqual(items[0].id, f1.id); assert.strictEqual(items[1].id, f2.id); }); + + test('preserves optional feedback context fields', () => { + const feedback = service.addFeedback(session, fileA, r(10), 'with context', undefined, { + codeSelection: 'const value = 1;', + diffHunks: '@@ -1,1 +1,1 @@\n-const value = 0;\n+const value = 1;', + }); + + assert.strictEqual(feedback.codeSelection, 'const value = 1;'); + assert.strictEqual(feedback.diffHunks, '@@ -1,1 +1,1 @@\n-const value = 0;\n+const value = 1;'); + }); }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts new file mode 100644 index 0000000000000..4e003899579d9 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { getResourceEditorComments, getSessionEditorComments, groupNearbySessionEditorComments, hasAgentFeedbackComments, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; +import { ICodeReviewState, CodeReviewStateKind, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; + +type ICodeReviewResultState = Extract; + +suite('SessionEditorComments', () => { + const session = URI.parse('test://session/1'); + const fileA = URI.parse('file:///a.ts'); + const fileB = URI.parse('file:///b.ts'); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function reviewState(comments: ICodeReviewResultState['comments']): ICodeReviewState { + return { + kind: CodeReviewStateKind.Result, + version: 'v1', + reviewCount: 1, + comments, + didProduceComments: comments.length > 0, + }; + } + + test('merges and sorts feedback and review comments by resource and range', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-b', text: 'feedback b', resourceUri: fileB, range: new Range(8, 1, 8, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(12, 1, 12, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.deepStrictEqual(comments.map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:3:codeReview', + '/a.ts:12:agentFeedback', + '/b.ts:2:codeReview', + '/b.ts:8:agentFeedback', + ]); + }); + + test('groups nearby comments only within the same resource', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(10, 1, 10, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + const groups = groupNearbySessionEditorComments(comments, 5); + assert.strictEqual(groups.length, 2); + assert.deepStrictEqual(groups[0].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:10:agentFeedback', + '/a.ts:13:codeReview', + ]); + assert.deepStrictEqual(groups[1].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/b.ts:11:codeReview', + ]); + }); + + test('preserves review suggestion metadata and capability flags', () => { + const comments = getSessionEditorComments(session, [], reviewState([ + { + id: 'review-suggestion', + uri: fileA, + range: new Range(7, 1, 7, 1), + body: 'prefer a constant', + kind: 'suggestion', + severity: 'info', + suggestion: { + edits: [{ range: new Range(7, 1, 7, 10), oldText: 'let value', newText: 'const value' }], + }, + }, + ])); + + assert.strictEqual(comments.length, 1); + assert.strictEqual(comments[0].source, SessionEditorCommentSource.CodeReview); + assert.strictEqual(comments[0].canConvertToAgentFeedback, true); + assert.strictEqual(comments[0].suggestion?.edits[0].newText, 'const value'); + }); + + test('filters resource comments and detects authored feedback presence', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(1, 1, 1, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.strictEqual(hasAgentFeedbackComments(comments), true); + assert.deepStrictEqual(getResourceEditorComments(fileA, comments).map(comment => comment.source), [SessionEditorCommentSource.AgentFeedback]); + assert.deepStrictEqual(getResourceEditorComments(fileB, comments).map(comment => comment.source), [SessionEditorCommentSource.CodeReview]); + }); + + test('includes PR review comments when prReviewState is loaded', () => { + const prState: IPRReviewState = { + kind: PRReviewStateKind.Loaded, + comments: [ + { id: 'pr-thread-1', uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, + { id: 'pr-thread-2', uri: fileB, range: new Range(1, 1, 1, 1), body: 'Looks wrong', author: 'reviewer' }, + ], + }; + + const comments = getSessionEditorComments(session, [], reviewState([]), prState); + assert.strictEqual(comments.length, 2); + assert.deepStrictEqual(comments.map(c => `${c.resourceUri.path}:${c.range.startLineNumber}:${c.source}`), [ + '/a.ts:5:prReview', + '/b.ts:1:prReview', + ]); + assert.strictEqual(comments[0].canConvertToAgentFeedback, true); + }); + + test('merges PR review comments with other sources sorted correctly', () => { + const prState: IPRReviewState = { + kind: PRReviewStateKind.Loaded, + comments: [ + { id: 'pr-thread-1', uri: fileA, range: new Range(7, 1, 7, 1), body: 'PR comment', author: 'reviewer' }, + ], + }; + + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(3, 1, 3, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(10, 1, 10, 1), body: 'review', kind: 'issue', severity: 'warning' }, + ]), prState); + + assert.strictEqual(comments.length, 3); + assert.deepStrictEqual(comments.map(c => `${c.range.startLineNumber}:${c.source}`), [ + '3:agentFeedback', + '7:prReview', + '10:codeReview', + ]); + }); + + test('omits PR review comments when prReviewState is not loaded', () => { + const prState: IPRReviewState = { kind: PRReviewStateKind.None }; + const comments = getSessionEditorComments(session, [], reviewState([]), prState); + assert.strictEqual(comments.length, 0); + }); +}); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts index 7e335ef732616..b9fd774f52a77 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts @@ -24,10 +24,12 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; const $ = DOM.$; @@ -67,6 +69,8 @@ export class AICustomizationOverviewView extends ViewPane { @IPromptsService private readonly promptsService: IPromptsService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IMcpService private readonly mcpService: IMcpService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -76,6 +80,8 @@ export class AICustomizationOverviewView extends ViewPane { { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 }, { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 }, { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon, count: 0 }, + { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, count: 0 }, + { id: AICustomizationManagementSection.Plugins, label: localize('plugins', "Plugins"), icon: pluginIcon, count: 0 }, ); // Listen to changes @@ -173,6 +179,26 @@ export class AICustomizationOverviewView extends ViewPane { } })); + // Update MCP server count reactively + const mcpSection = this.sections.find(s => s.id === AICustomizationManagementSection.McpServers); + if (mcpSection) { + this._register(autorun(reader => { + const servers = this.mcpService.servers.read(reader); + mcpSection.count = servers.length; + this.updateCountElements(); + })); + } + + // Update plugin count reactively + const pluginSection = this.sections.find(s => s.id === AICustomizationManagementSection.Plugins); + if (pluginSection) { + this._register(autorun(reader => { + const plugins = this.agentPluginService.plugins.read(reader); + pluginSection.count = plugins.length; + this.updateCountElements(); + })); + } + this.updateCountElements(); } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts index a7edc620be229..3258fa7a59571 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -7,13 +7,19 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; -import { AICustomizationItemTypeContextKey } from './aiCustomizationTreeViewViews.js'; +import { AI_CUSTOMIZATION_VIEW_ID, AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationItemDisabledContextKey, AICustomizationItemStorageContextKey, AICustomizationItemTypeContextKey, AICustomizationViewPane } from './aiCustomizationTreeViewViews.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IFileService, FileSystemProviderCapabilities } from '../../../../platform/files/common/files.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; //#region Utilities @@ -21,13 +27,13 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi * Type for context passed to actions from tree context menus. * Handles both direct URI arguments and serialized context objects. */ -type URIContext = { uri: URI | string;[key: string]: unknown } | URI | string; +type ItemContext = { uri: URI | string; promptType?: string; disabled?: boolean;[key: string]: unknown } | URI | string; /** * Extracts a URI from various context formats. * Context can be a URI, string, or an object with uri property. */ -function extractURI(context: URIContext): URI { +function extractURI(context: ItemContext): URI { if (URI.isUri(context)) { return context; } @@ -54,7 +60,7 @@ registerAction2(class extends Action2 { icon: Codicon.goToFile, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const editorService = accessor.get(IEditorService); await editorService.openEditor({ resource: extractURI(context) @@ -73,13 +79,72 @@ registerAction2(class extends Action2 { icon: Codicon.play, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); } }); +// Delete file action +const DELETE_AI_CUSTOMIZATION_FILE_ID = 'aiCustomization.deleteFile'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DELETE_AI_CUSTOMIZATION_FILE_ID, + title: localize2('delete', "Delete"), + icon: Codicon.trash, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + const fileService = accessor.get(IFileService); + const dialogService = accessor.get(IDialogService); + const uri = extractURI(context); + const name = typeof context === 'object' && !URI.isUri(context) ? (context as { name?: string }).name ?? '' : ''; + + if (uri.scheme !== 'file') { + return; + } + + const confirmation = await dialogService.confirm({ + message: localize('confirmDelete', "Are you sure you want to delete '{0}'?", name || uri.path), + primaryButton: localize('delete', "Delete"), + }); + + if (confirmation.confirmed) { + const useTrash = fileService.hasCapability(uri, FileSystemProviderCapabilities.Trash); + await fileService.del(uri, { useTrash, recursive: true }); + } + } +}); + +// Copy path action +const COPY_AI_CUSTOMIZATION_PATH_ID = 'aiCustomization.copyPath'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: COPY_AI_CUSTOMIZATION_PATH_ID, + title: localize2('copyPath', "Copy Path"), + icon: Codicon.clippy, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + const clipboardService = accessor.get(IClipboardService); + const uri = extractURI(context); + const textToCopy = uri.scheme === 'file' ? uri.fsPath : uri.toString(true); + await clipboardService.writeText(textToCopy); + } +}); + // Register context menu items + +// Inline hover actions (shown as icon buttons on hover) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DELETE_AI_CUSTOMIZATION_FILE_ID, title: localize('delete', "Delete"), icon: Codicon.trash }, + group: 'inline', + order: 10, +}); + +// Context menu items (shown on right-click) MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { command: { id: OPEN_AI_CUSTOMIZATION_FILE_ID, title: localize('open', "Open") }, group: '1_open', @@ -93,4 +158,126 @@ MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { when: ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.prompt), }); +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: COPY_AI_CUSTOMIZATION_PATH_ID, title: localize('copyPath', "Copy Path") }, + group: '3_modify', + order: 1, +}); + +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DELETE_AI_CUSTOMIZATION_FILE_ID, title: localize('delete', "Delete") }, + group: '3_modify', + order: 10, +}); + +// Disable item action +const DISABLE_AI_CUSTOMIZATION_ITEM_ID = 'aiCustomization.disableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, + title: localize2('disable', "Disable"), + icon: Codicon.eyeClosed, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + if (typeof context !== 'object' || URI.isUri(context)) { + return; + } + const promptsService = accessor.get(IPromptsService); + const viewsService = accessor.get(IViewsService); + const uri = extractURI(context); + const promptType = context.promptType as PromptsType | undefined; + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + + const view = viewsService.getActiveViewWithId(AI_CUSTOMIZATION_VIEW_ID); + view?.refresh(); + } +}); + +// Enable item action +const ENABLE_AI_CUSTOMIZATION_ITEM_ID = 'aiCustomization.enableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, + title: localize2('enable', "Enable"), + icon: Codicon.eye, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + if (typeof context !== 'object' || URI.isUri(context)) { + return; + } + const promptsService = accessor.get(IPromptsService); + const viewsService = accessor.get(IViewsService); + const uri = extractURI(context); + const promptType = context.promptType as PromptsType | undefined; + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.delete(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + + const view = viewsService.getActiveViewWithId(AI_CUSTOMIZATION_VIEW_ID); + view?.refresh(); + } +}); + +// Context menu: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('disable', "Disable") }, + group: '4_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, false), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Context menu: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('enable', "Enable") }, + group: '4_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, true), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Inline hover: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, false), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Inline hover: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, true), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + //#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index d2e6fcf1933d6..f5cfae860f42e 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -5,6 +5,7 @@ import './media/aiCustomizationTreeView.css'; import * as dom from '../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -12,7 +13,7 @@ import { basename, dirname } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { createActionViewItem, getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IMenuService } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; @@ -27,8 +28,12 @@ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; +import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; @@ -49,6 +54,16 @@ export const AICustomizationIsEmptyContextKey = new RawContextKey('aiCu */ export const AICustomizationItemTypeContextKey = new RawContextKey('aiCustomizationItemType', ''); +/** + * Context key indicating whether the current item is disabled. + */ +export const AICustomizationItemDisabledContextKey = new RawContextKey('aiCustomizationItemDisabled', false); + +/** + * Context key for the current item's storage type in context menus. + */ +export const AICustomizationItemStorageContextKey = new RawContextKey('aiCustomizationItemStorage', ''); + //#endregion //#region Tree Item Types @@ -77,7 +92,7 @@ interface IAICustomizationGroupItem { readonly type: 'group'; readonly id: string; readonly label: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; readonly icon: ThemeIcon; } @@ -91,11 +106,23 @@ interface IAICustomizationFileItem { readonly uri: URI; readonly name: string; readonly description?: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; + readonly disabled: boolean; } -type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem; +/** + * Represents a link item that navigates to the management editor. + */ +interface IAICustomizationLinkItem { + readonly type: 'link'; + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly section: AICustomizationManagementSection; +} + +type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem | IAICustomizationLinkItem; //#endregion @@ -109,6 +136,7 @@ class AICustomizationTreeDelegate implements IListVirtualDelegate { +class AICustomizationCategoryRenderer implements ITreeRenderer { readonly templateId = 'category'; renderTemplate(container: HTMLElement): ICategoryTemplateData { @@ -145,7 +176,7 @@ class AICustomizationCategoryRenderer implements ITreeRenderer, _index: number, templateData: ICategoryTemplateData): void { + renderElement(node: ITreeNode, _index: number, templateData: ICategoryTemplateData): void { templateData.icon.className = 'icon'; templateData.icon.classList.add(...ThemeIcon.asClassNameArray(node.element.icon)); templateData.label.textContent = node.element.label; @@ -173,15 +204,29 @@ class AICustomizationGroupRenderer implements ITreeRenderer { readonly templateId = 'file'; + constructor( + private readonly menuService: IMenuService, + private readonly contextKeyService: IContextKeyService, + private readonly instantiationService: IInstantiationService, + ) { } + renderTemplate(container: HTMLElement): IFileTemplateData { const element = dom.append(container, dom.$('.ai-customization-tree-item')); const icon = dom.append(element, dom.$('.icon')); const name = dom.append(element, dom.$('.name')); - return { container: element, icon, name }; + const actionsContainer = dom.append(element, dom.$('.actions')); + + const templateDisposables = new DisposableStore(); + const actionBar = templateDisposables.add(new ActionBar(actionsContainer, { + actionViewItemProvider: createActionViewItem.bind(undefined, this.instantiationService), + })); + + return { container: element, icon, name, actionBar, elementDisposables: new DisposableStore(), templateDisposables }; } renderElement(node: ITreeNode, _index: number, templateData: IFileTemplateData): void { const item = node.element; + templateData.elementDisposables.clear(); // Set icon based on prompt type let icon: ThemeIcon; @@ -206,12 +251,53 @@ class AICustomizationFileRenderer implements ITreeRenderer { + const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); + const { primary } = getContextMenuActions(actions, 'inline'); + templateData.actionBar.clear(); + templateData.actionBar.push(primary, { icon: true, label: false }); + }; + updateActions(); + templateData.elementDisposables.add(menu.onDidChange(updateActions)); + + templateData.actionBar.context = context; + } + + disposeElement(_node: ITreeNode, _index: number, templateData: IFileTemplateData): void { + templateData.elementDisposables.clear(); } - disposeTemplate(_templateData: IFileTemplateData): void { } + disposeTemplate(templateData: IFileTemplateData): void { + templateData.templateDisposables.dispose(); + templateData.elementDisposables.dispose(); + } } /** @@ -219,7 +305,7 @@ class AICustomizationFileRenderer implements ITreeRenderer; + files?: Map; } /** @@ -248,6 +334,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource s.storage === PromptsStorage.local); const userSkills = cached.skills.filter(s => s.storage === PromptsStorage.user); const extensionSkills = cached.skills.filter(s => s.storage === PromptsStorage.extension); + const builtinSkills = cached.skills.filter(s => s.storage === BUILTIN_STORAGE); if (workspaceSkills.length > 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceSkills.length)); @@ -340,6 +437,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionSkills.length)); } + if (builtinSkills.length > 0) { + groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinSkills.length)); + } return groups; } @@ -350,11 +450,13 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource item.storage === PromptsStorage.local); const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); - cached.files = new Map([ + cached.files = new Map([ [PromptsStorage.local, workspaceItems], [PromptsStorage.user, userItems], [PromptsStorage.extension, extensionItems], + [BUILTIN_STORAGE, builtinItems], ]); const itemCount = allItems.length; @@ -365,6 +467,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); @@ -375,6 +478,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length)); } + if (builtinItems.length > 0) { + groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinItems.length)); + } return groups; } @@ -382,23 +488,29 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { + private createGroupItem(promptType: PromptsType, storage: AICustomizationPromptsStorage, count: number): IAICustomizationGroupItem { + const storageLabels: Record = { [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), + [PromptsStorage.plugin]: localize('pluginsWithCount', "Plugins ({0})", count), + [BUILTIN_STORAGE]: localize('builtinWithCount', "Built-in ({0})", count), }; - const storageIcons: Record = { + const storageIcons: Record = { [PromptsStorage.local]: workspaceIcon, [PromptsStorage.user]: userIcon, [PromptsStorage.extension]: extensionIcon, + [PromptsStorage.plugin]: pluginIcon, + [BUILTIN_STORAGE]: builtinIcon, }; - const storageSuffixes: Record = { + const storageSuffixes: Record = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extensions', + [PromptsStorage.plugin]: 'plugins', + [BUILTIN_STORAGE]: 'builtin', }; return { @@ -415,15 +527,18 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private async getFilesForStorageAndType(storage: AICustomizationPromptsStorage, promptType: PromptsType): Promise { const cached = this.cache.get(promptType); + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); - // For skills, use the cached skills data + // For skills, use the cached skills data and merge in disabled skills if (promptType === PromptsType.skill) { const skills = cached?.skills || []; const filtered = skills.filter(skill => skill.storage === storage); - return filtered + const seenUris = new Set(); + const result: IAICustomizationFileItem[] = filtered .map(skill => { + seenUris.add(skill.uri.toString()); // Use skill name from frontmatter, or fallback to parent folder name const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); return { @@ -434,8 +549,30 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); + for (const file of allSkillFiles) { + if (file.storage === storage && !seenUris.has(file.uri.toString()) && disabledUris.has(file.uri)) { + result.push({ + type: 'file' as const, + id: file.uri.toString(), + uri: file.uri, + name: file.name || basename(dirname(file.uri)) || basename(file.uri), + description: file.description, + storage: file.storage, + promptType, + disabled: true, + }); + } + } + } + + return result; } // Use cached files data (already fetched in getStorageGroups) @@ -448,6 +585,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource; private readonly itemTypeContextKey: IContextKey; + private readonly itemDisabledContextKey: IContextKey; + private readonly itemStorageContextKey: IContextKey; constructor( options: IViewPaneOptions, @@ -494,6 +634,8 @@ export class AICustomizationViewPane extends ViewPane { // Initialize context keys this.isEmptyContextKey = AICustomizationIsEmptyContextKey.bindTo(contextKeyService); this.itemTypeContextKey = AICustomizationItemTypeContextKey.bindTo(contextKeyService); + this.itemDisabledContextKey = AICustomizationItemDisabledContextKey.bindTo(contextKeyService); + this.itemStorageContextKey = AICustomizationItemStorageContextKey.bindTo(contextKeyService); // Subscribe to prompt service events to refresh tree this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); @@ -537,7 +679,7 @@ export class AICustomizationViewPane extends ViewPane { [ new AICustomizationCategoryRenderer(), new AICustomizationGroupRenderer(), - new AICustomizationFileRenderer(), + new AICustomizationFileRenderer(this.menuService, this.contextKeyService, this.instantiationService), ], this.dataSource, { @@ -546,16 +688,19 @@ export class AICustomizationViewPane extends ViewPane { }, accessibilityProvider: { getAriaLabel: (element: AICustomizationTreeItem) => { - if (element.type === 'category') { + if (element.type === 'category' || element.type === 'link') { return element.label; } if (element.type === 'group') { return element.label; } - // For files, include description if available - return element.description + // For files, include description and disabled state + const nameAndDesc = element.description ? localize('fileAriaLabel', "{0}, {1}", element.name, element.description) : element.name; + return element.disabled + ? localize('fileAriaLabelDisabled', "{0}, disabled", nameAndDesc) + : nameAndDesc; }, getWidgetAriaLabel: () => localize('aiCustomizationTree', "Chat Customization Items"), }, @@ -570,12 +715,18 @@ export class AICustomizationViewPane extends ViewPane { } )); - // Handle double-click to open file - this.treeDisposables.add(this.tree.onDidOpen(e => { + // Handle double-click to open file or navigate to section + this.treeDisposables.add(this.tree.onDidOpen(async e => { if (e.element && e.element.type === 'file') { this.editorService.openEditor({ - resource: e.element.uri + resource: e.element.uri, }); + } else if (e.element && e.element.type === 'link') { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorService.openEditor(input, { pinned: true }); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(e.element.section); + } } })); @@ -627,14 +778,17 @@ export class AICustomizationViewPane extends ViewPane { const element = e.element; - // Set context key for the item type so menu items can use `when` clauses + // Set context keys for the item so menu items can use `when` clauses this.itemTypeContextKey.set(element.promptType); + this.itemDisabledContextKey.set(element.disabled); + this.itemStorageContextKey.set(element.storage); // Get menu actions from the menu service const context = { uri: element.uri.toString(), name: element.name, promptType: element.promptType, + disabled: element.disabled, }; const menu = this.menuService.getMenuActions(AICustomizationItemMenuId, this.contextKeyService, { arg: context, shouldForwardArgs: true }); const { secondary } = getContextMenuActions(menu, 'inline'); @@ -646,8 +800,10 @@ export class AICustomizationViewPane extends ViewPane { getActions: () => secondary, getActionsContext: () => context, onHide: () => { - // Clear the context key when menu closes + // Clear the context keys when menu closes this.itemTypeContextKey.reset(); + this.itemDisabledContextKey.reset(); + this.itemStorageContextKey.reset(); }, }); } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css index 0756725fc2b39..d9e40517d16fe 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css @@ -31,11 +31,24 @@ } .ai-customization-view .ai-customization-tree-item .name { + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ai-customization-view .ai-customization-tree-item .actions { + display: none; + flex-shrink: 0; + max-width: fit-content; +} + +.ai-customization-view .monaco-list .monaco-list-row:hover .ai-customization-tree-item > .actions, +.ai-customization-view .monaco-list .monaco-list-row.focused .ai-customization-tree-item > .actions, +.ai-customization-view .monaco-list .monaco-list-row.selected .ai-customization-tree-item > .actions { + display: flex; +} + .ai-customization-view .ai-customization-tree-item .description { flex-shrink: 1; color: var(--vscode-descriptionForeground); @@ -90,6 +103,11 @@ white-space: nowrap; } +/* Disabled items */ +.ai-customization-view .ai-customization-tree-item.disabled { + opacity: 0.5; +} + /* Empty state */ .ai-customization-view .empty-message { padding: 10px; diff --git a/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts new file mode 100644 index 0000000000000..c3d198e1d161e --- /dev/null +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { URI } from '../../../../base/common/uri.js'; + +const hasWorktreeAndRepositoryContextKey = new RawContextKey('agentSessionHasWorktreeAndRepository', false, { + type: 'boolean', + description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.") +}); + +class ApplyChangesToParentRepoContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.applyChangesToParentRepo'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const worktreeAndRepoKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService); + + this._register(autorun(reader => { + const activeSession = sessionManagementService.activeSession.read(reader); + const repo = activeSession?.workspace.read(reader)?.repositories[0]; + const hasWorktreeAndRepo = !!repo?.workingDirectory && !!repo?.uri; + worktreeAndRepoKey.set(hasWorktreeAndRepo); + })); + } +} + +class ApplyChangesToParentRepoAction extends Action2 { + static readonly ID = 'chatEditing.applyChangesToParentRepo'; + + constructor() { + super({ + id: ApplyChangesToParentRepoAction.ID, + title: localize2('applyChangesToParentRepo', 'Apply Changes to Parent Repository'), + icon: Codicon.desktopDownload, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + IsSessionsWindowContext, + hasWorktreeAndRepositoryContextKey, + ), + menu: [ + { + id: MenuId.ChatEditingSessionApplySubmenu, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.false(), + IsSessionsWindowContext, + hasWorktreeAndRepositoryContextKey + ), + }, + ], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const commandService = accessor.get(ICommandService); + const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); + const openerService = accessor.get(IOpenerService); + const productService = accessor.get(IProductService); + + const activeSession = sessionManagementService.activeSession.get(); + const repo = activeSession?.workspace.get()?.repositories[0]; + if (!activeSession || !repo?.workingDirectory || !repo?.uri) { + return; + } + + const worktreeRoot = repo.workingDirectory; + const repoRoot = repo.uri; + + const openFolderAction = toAction({ + id: 'applyChangesToParentRepo.openFolder', + label: localize('openInVSCode', "Open in VS Code"), + run: () => { + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : 'vscode-insiders'; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + params.set('session', activeSession.resource.toString()); + + openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: repoRoot.path, + query: params.toString(), + }), { openExternal: true }); + } + }); + + try { + // Get the worktree branch name. Since the worktree and parent repo + // share the same git object store, the parent can directly reference + // this branch for a merge. + const worktreeBranch = await commandService.executeCommand( + '_git.revParseAbbrevRef', + worktreeRoot.fsPath + ); + + if (!worktreeBranch) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('applyChangesNoBranch', "Could not determine worktree branch name."), + }); + return; + } + + // Merge the worktree branch into the parent repo. + // This is idempotent: if already merged, git says "Already up to date." + // If new commits exist, they're brought in. Handles partial applies naturally. + const result = await commandService.executeCommand('_git.mergeBranch', repoRoot.fsPath, worktreeBranch); + if (!result) { + logService.warn('[ApplyChangesToParentRepo] No result from merge command'); + } else { + notificationService.notify({ + severity: Severity.Info, + message: typeof result === 'string' && result.startsWith('Already up to date') + ? localize('alreadyUpToDate', 'Parent repository is up to date with worktree.') + : localize('applyChangesSuccess', 'Applied changes to parent repository.'), + actions: { primary: [openFolderAction] } + }); + } + } catch (err) { + logService.error('[ApplyChangesToParentRepo] Failed to apply changes', err); + notificationService.notify({ + severity: Severity.Warning, + message: localize('applyChangesConflict', "Failed to apply changes to parent repo. The parent repo may have diverged — resolve conflicts manually."), + actions: { primary: [openFolderAction] } + }); + } + } +} + +registerAction2(ApplyChangesToParentRepoAction); +registerWorkbenchContribution2(ApplyChangesToParentRepoContribution.ID, ApplyChangesToParentRepoContribution, WorkbenchPhase.AfterRestored); + +// Register the apply submenu in the session changes toolbar +MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, { + submenu: MenuId.ChatEditingSessionApplySubmenu, + title: localize2('applyActions', 'Apply Actions'), + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), +}); diff --git a/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts new file mode 100644 index 0000000000000..5519f5024f7eb --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesTitleBarWidget.css'; + +import { $, append } from '../../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IsAuxiliaryWindowContext, AuxiliaryBarVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { getAgentChangesSummary } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IPaneCompositePartService } from '../../../../workbench/services/panecomposite/browser/panecomposite.js'; +import { ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CHANGES_VIEW_CONTAINER_ID } from './changesView.js'; + +const TOGGLE_CHANGES_VIEW_ID = 'workbench.action.agentSessions.toggleChangesView'; + +/** + * Action view item that renders the diff stats indicator (file change counts) + * in the titlebar session toolbar. Shows [diff icon] +insertions -deletions. + * Clicking toggles the auxiliary bar with the Changes view. + */ +class ChangesTitleBarActionViewItem extends BaseActionViewItem { + + private _container: HTMLElement | undefined; + private readonly _indicatorDisposables = this._register(new DisposableStore()); + private readonly _hoverDelegate = this._register(createInstantHoverDelegate()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { + super(undefined, action, options); + + // Re-render when the active session changes + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this._rebuildIndicators(); + })); + + // Re-render when sessions data changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._rebuildIndicators(); + })); + + // Update active state when auxiliary bar visibility changes + this._register(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.AUXILIARYBAR_PART) { + this._updateActiveState(); + } + })); + } + + override render(container: HTMLElement): void { + super.render(container); + + this._container = container; + container.classList.add('changes-titlebar-indicator'); + + this._rebuildIndicators(); + this._updateActiveState(); + } + + override onClick(): void { + this._action.run(); + } + + private _updateActiveState(): void { + this._container?.classList.toggle('active', this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + } + + private _rebuildIndicators(): void { + if (!this._container) { + return; + } + + this._indicatorDisposables.clear(); + + const btn = this._container; + btn.textContent = ''; + + // Get change summary from the active session + const activeSession = this.activeSessionService.activeSession.get(); + const resource = activeSession?.resource; + const session = resource ? this.agentSessionsService.getSession(resource) : undefined; + const summary = session ? getAgentChangesSummary(session.changes) : undefined; + + // Rebuild inner content: [diff icon] +insertions -deletions + append(btn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); + + if (summary && summary.insertions > 0) { + const insLabel = append(btn, $('span.changes-titlebar-count.changes-titlebar-insertions')); + insLabel.textContent = `+${summary.insertions}`; + } + + if (summary && summary.deletions > 0) { + const delLabel = append(btn, $('span.changes-titlebar-count.changes-titlebar-deletions')); + delLabel.textContent = `-${summary.deletions}`; + } + + if (summary) { + this._indicatorDisposables.add(this.hoverService.setupManagedHover( + this._hoverDelegate, btn, + localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions) + )); + } else { + this._indicatorDisposables.add(this.hoverService.setupManagedHover( + this._hoverDelegate, btn, + localize('showChanges', "Show Changes") + )); + } + } +} + +/** + * Registers the changes indicator action in the titlebar session toolbar + * (`TitleBarSessionMenu`) and provides a custom action view item to render + * the diff stats widget. + */ +export class ChangesTitleBarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.changesTitleBar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + // Register the toggle action in the session toolbar + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarSessionMenu, { + command: { + id: TOGGLE_CHANGES_VIEW_ID, + title: localize('toggleChanges', "Toggle Changes"), + icon: Codicon.diffMultiple, + toggled: AuxiliaryBarVisibleContext, + }, + group: 'navigation', + order: 10, // After Run Script (8) and Terminal toggle (9) + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + })); + + // Provide a custom action view item that renders the diff stats + this._register(actionViewItemService.register(Menus.TitleBarSessionMenu, TOGGLE_CHANGES_VIEW_ID, (action, options) => { + return instantiationService.createInstance(ChangesTitleBarActionViewItem, action, options); + })); + } +} + +// Register the toggle action +registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_CHANGES_VIEW_ID, + title: localize('toggleChanges', "Toggle Changes"), + icon: Codicon.diffMultiple, + precondition: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + const paneCompositeService = accessor.get(IPaneCompositePartService); + + const isVisible = !layoutService.isVisible(Parts.AUXILIARYBAR_PART); + layoutService.setPartHidden(!isVisible, Parts.AUXILIARYBAR_PART); + if (isVisible) { + paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + } + } +}); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts similarity index 79% rename from src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts rename to src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 3e69bba8dfa4f..d365c787507ea 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -8,8 +8,13 @@ import { localize2 } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import './changesViewActions.js'; +import './fixCIChecksAction.js'; +import { ChangesViewController } from './changesViewController.js'; +import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -38,3 +43,6 @@ viewsRegistry.registerViews([{ order: 1, windowVisibility: WindowVisibility.Sessions }], changesViewContainer); + +registerWorkbenchContribution2(ChangesViewController.ID, ChangesViewController, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChangesTitleBarContribution.ID, ChangesTitleBarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts similarity index 53% rename from src/vs/sessions/contrib/changesView/browser/changesView.ts rename to src/vs/sessions/contrib/changes/browser/changesView.ts index 973e8e2ba1b4b..5b5d05751cf70 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -10,20 +10,19 @@ import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressed import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; -import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -44,18 +43,25 @@ import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/la import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; -import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; +import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { CIStatusWidget } from './ciStatusWidget.js'; +import { arrayEqualsC } from '../../../../base/common/equals.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; const $ = dom.$; @@ -63,6 +69,7 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; +const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -73,6 +80,19 @@ export const enum ChangesViewMode { const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); +// --- Versions Mode + +const enum ChangesVersionMode { + AllChanges = 'allChanges', + LastTurn = 'lastTurn' +} + +const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); +const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); +const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); +const hasIncomingChangesContextKey = new RawContextKey('sessions.hasIncomingChanges', false); +const hasOutgoingChangesContextKey = new RawContextKey('sessions.hasOutgoingChanges', false); + // --- List Item type ChangeType = 'added' | 'modified' | 'deleted'; @@ -86,6 +106,7 @@ interface IChangesFileItem { readonly changeType: ChangeType; readonly linesAdded: number; readonly linesRemoved: number; + readonly reviewCommentCount: number; } interface IChangesFolderItem { @@ -94,11 +115,6 @@ interface IChangesFolderItem { readonly name: string; } -interface IActiveSession { - readonly resource: URI; - readonly sessionType: string; -} - type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { @@ -124,17 +140,33 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement= 3) { + uriBasePrefix = '/' + parts.slice(0, 3).join('/'); + displayDirPath = '/' + parts.slice(3).join('/'); + } else { + uriBasePrefix = '/' + parts.join('/'); + displayDirPath = '/'; + } + } + + const segments = displayDirPath.split('/').filter(Boolean); let current = root; - let currentPath = ''; + let currentFullPath = uriBasePrefix; for (const segment of segments) { - currentPath += '/' + segment; + currentFullPath += '/' + segment; if (!current.children.has(segment)) { current.children.set(segment, { name: segment, - uri: item.uri.with({ path: currentPath }), + uri: item.uri.with({ path: currentFullPath }), children: new Map(), files: [] }); @@ -171,6 +203,99 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; + readonly activeSessionResourceObs: IObservable; + readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionChangesObs: IObservable; + + readonly versionModeObs: ISettableObservable; + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + } + + readonly viewModeObs: ISettableObservable; + setViewMode(mode: ChangesViewMode): void { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); + } + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IGitService private readonly gitService: IGitService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Active session changes + this.sessionsChangedSignal = observableSignalFromEvent(this, + this.agentSessionsService.model.onDidChangeSessions); + + // Active session resource + this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + // Active session changes + this.activeSessionChangesObs = derivedOpts({ + equalsFn: arrayEqualsC() + }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + if (!activeSession) { + return Iterable.empty(); + } + return activeSession.changes.read(reader) as readonly (IChatSessionFileChange | IChatSessionFileChange2)[]; + }); + + // Active session repository + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return constObservable(undefined); + } + + const activeSession = this.sessionManagementService.activeSession.read(reader); + const worktree = activeSession?.workspace.read(reader)?.repositories[0]?.workingDirectory; + if (!worktree) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(worktree)).resolvedValue; + }); + + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + // Version mode + this.versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + + this._register(runOnChange(this.activeSessionResourceObs, () => { + this.setVersionMode(ChangesVersionMode.AllChanges); + })); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + } +} + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -185,6 +310,7 @@ export class ChangesViewPane extends ViewPane { private actionsContainer: HTMLElement | undefined; private tree: WorkbenchCompressibleObjectTree | undefined; + private ciStatusWidget: CIStatusWidget | undefined; private readonly renderDisposables = this._register(new DisposableStore()); @@ -192,31 +318,7 @@ export class ChangesViewPane extends ViewPane { private currentBodyHeight = 0; private currentBodyWidth = 0; - // View mode (list vs tree) - private readonly viewModeObs: ReturnType>; - private readonly viewModeContextKey: IContextKey; - - get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } - set viewMode(mode: ChangesViewMode) { - if (this.viewModeObs.get() === mode) { - return; - } - this.viewModeObs.set(mode, undefined); - this.viewModeContextKey.set(mode); - this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); - } - - // Track the active session used by this view - private readonly activeSession: IObservableWithChange; - private readonly activeSessionFileCountObs: IObservableWithChange; - private readonly activeSessionHasChangesObs: IObservableWithChange; - - get activeSessionHasChanges(): IObservable { - return this.activeSessionHasChangesObs; - } - - // Badge for file count - private readonly badgeDisposable = this._register(new MutableDisposable()); + readonly viewModel: ChangesViewModel; constructor( options: IViewPaneOptions, @@ -229,113 +331,52 @@ export class ChangesViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IEditorService private readonly editorService: IEditorService, @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, - @IStorageService private readonly storageService: IStorageService, + @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitHubService private readonly gitHubService: IGitHubService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - // View mode - const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); - const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; - this.viewModeObs = observableValue(this, initialMode); - this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); - this.viewModeContextKey.set(initialMode); - - // Track active session from sessions management service - this.activeSession = derivedOpts({ - equalsFn: (a, b) => isEqual(a?.resource, b?.resource), - }, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - if (!activeSession?.resource) { - return undefined; - } - - return { - resource: activeSession.resource, - sessionType: getChatSessionType(activeSession.resource), - }; - }).recomputeInitiallyAndOnChange(this._store); - - this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); - this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - - // Setup badge tracking - this.registerBadgeTracking(); + this.viewModel = this.instantiationService.createInstance(ChangesViewModel); + this._register(this.viewModel); - // Set chatSessionType on the view's context key service so ViewTitle - // menu items can use it in their `when` clauses. Update reactively - // when the active session changes. - const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this._register(autorun(reader => { - const activeSession = this.activeSession.read(reader); - viewSessionTypeKey.set(activeSession?.sessionType ?? ''); + // Version mode + this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); })); - } - private registerBadgeTracking(): void { - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = this.activeSessionFileCountObs.read(reader); - this.updateBadge(fileCount); + // View mode + this._register(bindContextKey(changesViewModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.viewModeObs.read(reader); })); - } - - private createActiveSessionFileCountObservable(): IObservableWithChange { - const activeSessionResource = this.activeSession.map(a => a?.resource); - - const sessionsChangedSignal = observableFromEvent( - this, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); - } - - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); - return derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return 0; - } + // Set chatSessionType on the view's context key service so ViewTitle menu items + // can use it in their `when` clauses. Update reactively when the active session + // changes. + this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.sessionType ?? ''; + })); - const isBackgroundSession = activeSession.sessionType === AgentSessionProviders.Background; + // Badge + const badgeDisposable = this._register(new MutableDisposable()); - let editingSessionCount = 0; - if (!isBackgroundSession) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - editingSessionCount = session ? session.entries.read(reader).length : 0; + this._register(autorun(reader => { + const changes = this.viewModel.activeSessionChangesObs.read(reader); + if (changes.length === 0) { + badgeDisposable.clear(); + return; } - const sessionFiles = [...sessionFileChangesObs.read(reader)]; - const sessionFilesCount = sessionFiles.length; - - return editingSessionCount + sessionFilesCount; - }).recomputeInitiallyAndOnChange(this._store); - } - - private updateBadge(fileCount: number): void { - if (fileCount > 0) { - const message = fileCount === 1 + const message = changes.length === 1 ? localize('changesView.oneFileChanged', '1 file changed') - : localize('changesView.filesChanged', '{0} files changed', fileCount); - this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); - } else { - this.badgeDisposable.clear(); - } + : localize('changesView.filesChanged', '{0} files changed', changes.length); + badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(changes.length, () => message) }); + })); } protected override renderBody(container: HTMLElement): void { @@ -371,6 +412,10 @@ export class ChangesViewPane extends ViewPane { // List container this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + // CI Status widget beneath the card + this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.bodyContainer)); + this._register(this.ciStatusWidget.onDidChangeHeight(() => this.layoutTree())); + this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { this.onVisible(); @@ -385,109 +430,170 @@ export class ChangesViewPane extends ViewPane { } } + override getActionsContext(): URI | undefined { + return this.viewModel.activeSessionResourceObs.get(); + } + private onVisible(): void { this.renderDisposables.clear(); - const activeSessionResource = this.activeSession.map(a => a?.resource); - - // Create observable for the active editing session - // Note: We must read editingSessionsObs to establish a reactive dependency, - // so that the view updates when a new editing session is added (e.g., cloud sessions) - const activeEditingSessionObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return undefined; - } - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - return sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - }); - // Create observable for edit session entries from the ACTIVE session only (local editing sessions) - const editSessionEntriesObs = derived(reader => { - const activeSession = this.activeSession.read(reader); + const reviewCommentCountByFileObs = derived(reader => { + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; - // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.sessionType === AgentSessionProviders.Background) { - return []; + if (!sessionResource) { + return new Map(); } - const session = activeEditingSessionObs.read(reader); - if (!session) { - return []; + const result = new Map(); + const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader); + if (prReviewState.kind === PRReviewStateKind.Loaded) { + for (const comment of prReviewState.comments) { + const uriKey = comment.uri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } } - const entries = session.entries.read(reader); - const items: IChangesFileItem[] = []; + if (changes.length === 0) { + return result; + } - for (const entry of entries) { - const isDeletion = entry.isDeletion ?? false; - const linesAdded = entry.linesAdded?.read(reader) ?? 0; - const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; + const reviewFiles = getCodeReviewFilesFromSessionChanges(changes as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); - items.push({ - type: 'file', - uri: entry.modifiedURI, - originalUri: entry.originalURI, - state: entry.state.read(reader), - isDeletion, - changeType: isDeletion ? 'deleted' : 'modified', - linesAdded, - linesRemoved, - }); + if (reviewState.kind !== CodeReviewStateKind.Result || reviewState.version !== reviewVersion) { + return result; } - return items; - }); - - // Signal observable that triggers when sessions data changes - const sessionsChangedSignal = observableFromEvent( - this.renderDisposables, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSession changes AND session data changes - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); + for (const comment of reviewState.comments) { + const uriKey = comment.uri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); + + return result; }); // Convert session file changes to list items (cloud/background sessions) - const sessionFilesObs = derived(reader => - [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const sessionFilesObs = derived(reader => { + const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; + + return changes.map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; + const uri = isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri; return { type: 'file', - uri: isIChatSessionFileChange2(entry) - ? entry.modifiedUri ?? entry.uri - : entry.modifiedUri, + uri, originalUri: entry.originalUri, state: ModifiedFileEntryState.Accepted, isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: entry.insertions, linesRemoved: entry.deletions, + reviewCommentCount: reviewCommentCountByFile.get(uri.fsPath) ?? 0, }; - }) - ); + }); + }); + + const headCommitObs = derived(reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + return repository?.state.read(reader)?.HEAD?.commit; + }); + + const lastCheckpointRefObs = derived(reader => { + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return undefined; + } + + this.viewModel.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + + return model?.metadata?.lastCheckpointRef as string | undefined; + }); + + const lastTurnChangesObs = derived(reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const headCommit = headCommitObs.read(reader); + + if (!repository || !headCommit) { + return constObservable(undefined); + } + + const lastCheckpointRef = lastCheckpointRefObs.read(reader); + + return lastCheckpointRef + ? new ObservablePromise(repository.diffBetweenWithStats(`${lastCheckpointRef}^`, lastCheckpointRef)).resolvedValue + : new ObservablePromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit)).resolvedValue; + }); // Combine both entry sources for display const combinedEntriesObs = derived(reader => { - const editEntries = editSessionEntriesObs.read(reader); + const headCommit = headCommitObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; + const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + const versionMode = this.viewModel.versionModeObs.read(reader); + + let sourceEntries: IChangesFileItem[]; + if (versionMode === ChangesVersionMode.LastTurn) { + const diffChanges = lastTurnDiffChanges ?? []; + const lastCheckpointRef = lastCheckpointRefObs.read(undefined); + + const ref = lastCheckpointRef + ? lastCheckpointRef + : headCommit; + + const parentRef = lastCheckpointRef + ? `${lastCheckpointRef}^` + : headCommit ? `${headCommit}^` : undefined; + + sourceEntries = diffChanges.map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + const uri = change.modifiedUri ?? change.uri; + const fileUri = isDeletion + ? uri + : ref + ? uri.with({ scheme: 'git', query: JSON.stringify({ path: uri.fsPath, ref }) }) + : uri; + const originalUri = isAddition + ? change.originalUri + : parentRef + ? fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: parentRef }) }) + : change.originalUri; + return { + type: 'file', + uri: fileUri, + originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + linesAdded: change.insertions, + linesRemoved: change.deletions, + reviewCommentCount: 0, + } satisfies IChangesFileItem; + }); + } else { + sourceEntries = [...sessionFiles]; + } + + const resources = new Set(); + const entries: IChangesFileItem[] = []; + for (const item of sourceEntries) { + if (!resources.has(item.uri.fsPath)) { + resources.add(item.uri.fsPath); + entries.push(item); + } + } + return entries.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); }); // Calculate stats from combined entries const topLevelStats = derived(reader => { - const editEntries = editSessionEntriesObs.read(reader); - const sessionFiles = sessionFilesObs.read(reader); const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -497,73 +603,126 @@ export class ChangesViewPane extends ViewPane { removed += entry.linesRemoved; } - const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; - - return { files, added, removed, isSessionMenu }; + return { files: entries.length, added, removed }; }); // Setup context keys and actions toolbar if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.actionsContainer)); - const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { + const { files } = topLevelStats.read(reader); + return files > 0; + })); - // Set the chat session type context key reactively so that menu items with - // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown - const chatSessionTypeKey = scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this.renderDisposables.add(autorun(reader => { - const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.sessionType ?? ''); + this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; })); - // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { + this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { + this.viewModel.sessionsChangedSignal.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + if (!sessionResource) { return false; } - const entries = session.entries.read(r); - return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); + + const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; + return metadata?.pullRequestUrl !== undefined; })); - this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.length > 0; + this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return (repositoryState?.HEAD?.behind ?? 0) > 0; })); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => { - const { files } = topLevelStats.read(r); - return files > 0; + const outgoingChangesObs = derived(reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return repositoryState?.HEAD?.ahead ?? 0; + }); + + this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const outgoingChanges = outgoingChangesObs.read(reader); + return outgoingChanges > 0; })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); + this.renderDisposables.add(autorun(reader => { - const { isSessionMenu, added, removed } = topLevelStats.read(reader); - const sessionResource = activeSessionResource.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + + // Read code review state to update the button label dynamically + let reviewCommentCount: number | undefined; + let codeReviewLoading = false; + if (sessionResource) { + const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader); + const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + const activeSession = this.sessionManagementService.activeSession.read(reader); + const sessionChanges = activeSession?.changes.read(reader); + if (sessionChanges && sessionChanges.length > 0) { + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) { + codeReviewLoading = true; + } else { + const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion ? reviewState.comments.length : 0; + const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount; + if (totalReviewCommentCount > 0) { + reviewCommentCount = totalReviewCommentCount; + } + } + } else if (prReviewCommentCount > 0) { + reviewCommentCount = prReviewCommentCount; + } + } + reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + MenuId.ChatEditingSessionChangesToolbar, { telemetrySource: 'changesView', - menuOptions: isSessionMenu && sessionResource + disableWhileRunning: true, + menuOptions: sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { if (action.id === 'chatEditing.viewChanges' || action.id === 'chatEditing.viewPreviousEdits' || action.id === 'chatEditing.viewAllSessionChanges' || action.id === 'chat.openSessionWorktreeInVSCode') { - const diffStatsLabel = new MarkdownString( - `+${added} -${removed}`, - { supportHtml: true } - ); - return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { + if (codeReviewLoading) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' }; + } + if (reviewCommentCount !== undefined) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewCommentCount), customClass: 'code-review-comments' }; + } + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'chatEditing.synchronizeChanges') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR') { + const customLabel = outgoingChanges > 0 + ? localize('updatePRWithOutgoingChanges', 'Update Pull Request {0}↑', outgoingChanges) + : localize('updatePR', 'Update Pull Request'); + + return { customLabel, showIcon: true, showLabel: true, isSecondary: false }; } - if (action.id === 'github.createPullRequest') { - return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; + if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge') { + return { showIcon: true, showLabel: true, isSecondary: false }; } return undefined; } @@ -608,7 +767,7 @@ export class ChangesViewPane extends ViewPane { 'ChangesViewTree', this.listContainer, new ChangesTreeDelegate(), - [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar)], + [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingSessionChangesToolbar)], { alwaysConsumeMouseWheel: false, accessibilityProvider: { @@ -642,11 +801,9 @@ export class ChangesViewPane extends ViewPane { }, compressionEnabled: true, twistieAdditionalCssClass: (e: unknown) => { - if (this.viewMode === ChangesViewMode.List) { - return 'force-no-twistie'; - } - // In tree mode, hide twistie for file items (they are never collapsible) - return isChangesFileItem(e as ChangesTreeElement) ? 'force-no-twistie' : undefined; + return this.viewModel.viewModeObs.get() === ChangesViewMode.List + ? 'force-no-twistie' + : undefined; }, } ); @@ -656,6 +813,9 @@ export class ChangesViewPane extends ViewPane { if (this.tree) { const tree = this.tree; + // Re-layout when collapse state changes so the card height adjusts + this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutTree())); + const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean) => { const { uri: modifiedFileUri, originalUri, isDeletion } = item; const currentIndex = items.indexOf(item); @@ -671,7 +831,7 @@ export class ChangesViewPane extends ViewPane { } }; - const group = sideBySide ? SIDE_GROUP : MODAL_GROUP; + const group = sideBySide ? SIDE_GROUP : ACTIVE_GROUP; if (isDeletion && originalUri) { this.editorService.openEditor({ @@ -706,10 +866,40 @@ export class ChangesViewPane extends ViewPane { })); } + // Bind CI status widget to active session's PR CI model + if (this.ciStatusWidget) { + const activeSessionResourceObs = derived(this, reader => this.sessionManagementService.activeSession.read(reader)?.resource); + + const ciModelObs = derived(this, reader => { + const session = this.sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = this.sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + const prModel = this.gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + // Use the PR's headSha (commit SHA) rather than the branch + // name so CI checks can still be fetched after branch deletion + // (e.g. after the PR is merged). + const ciModel = this.gitHubService.getPullRequestCI(context.owner, context.repo, pr.headSha); + ciModel.refresh(); + ciModel.startPolling(); + reader.store.add({ dispose: () => ciModel.stopPolling() }); + return ciModel; + }); + this.renderDisposables.add(this.ciStatusWidget.bind(ciModelObs, activeSessionResourceObs)); + } + // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); - const viewMode = this.viewModeObs.read(reader); + const viewMode = this.viewModel.viewModeObs.read(reader); if (!this.tree) { return; @@ -754,12 +944,52 @@ export class ChangesViewPane extends ViewPane { const containerPadding = 8; // 4px top + 4px bottom from .chat-editing-session-container const containerBorder = 2; // 1px top + 1px bottom border - const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; - const availableHeight = Math.max(0, bodyHeight - usedHeight); + const fixedUsed = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; + + // Determine CI widget space needs + const ciWidget = this.ciStatusWidget; + const ciVisible = ciWidget?.visible ?? false; + const ciHeaderHeight = ciVisible ? CIStatusWidget.HEADER_HEIGHT : 0; + const ciMargin = ciVisible ? 8 : 0; // margin-top on CI widget + const ciDesiredHeight = ciWidget?.desiredHeight ?? 0; + + const spaceForTreeAndCI = Math.max(0, bodyHeight - fixedUsed - ciMargin); + + // Give the tree priority, then CI gets the rest (with min/max on CI body) + const treeContentHeight = this.tree.contentHeight; + let treeHeight: number; + let ciBodyHeight = 0; - // Limit height to the content so the tree doesn't exceed its items - const contentHeight = this.tree.contentHeight; - const treeHeight = Math.min(availableHeight, contentHeight); + if (!ciVisible) { + treeHeight = Math.min(spaceForTreeAndCI, treeContentHeight); + } else { + // Reserve space for the CI header + const spaceAfterCIHeader = Math.max(0, spaceForTreeAndCI - ciHeaderHeight); + + // Give the tree what it needs first, up to available space + treeHeight = Math.min(spaceAfterCIHeader, treeContentHeight); + + // Remaining goes to CI body + const remainingForCIBody = Math.max(0, spaceAfterCIHeader - treeHeight); + const ciDesiredBodyHeight = Math.max(0, ciDesiredHeight - ciHeaderHeight); + + ciBodyHeight = Math.min(ciDesiredBodyHeight, remainingForCIBody); + + // Ensure CI body gets at least MIN_BODY_HEIGHT if there's content + if (ciDesiredBodyHeight > 0 && ciBodyHeight < CIStatusWidget.MIN_BODY_HEIGHT) { + const minCIBody = Math.min(CIStatusWidget.MIN_BODY_HEIGHT, ciDesiredBodyHeight); + const needed = minCIBody - ciBodyHeight; + const canTake = Math.max(0, treeHeight - 0); // tree can shrink to 0 + const taken = Math.min(needed, canTake); + treeHeight -= taken; + ciBodyHeight += taken; + } + + // Cap CI body at MAX_BODY_HEIGHT + ciBodyHeight = Math.min(ciBodyHeight, CIStatusWidget.MAX_BODY_HEIGHT); + + ciWidget!.layout(ciBodyHeight); + } this.tree.layout(treeHeight, this.currentBodyWidth); this.tree.getHTMLElement().style.height = `${treeHeight}px`; @@ -824,6 +1054,7 @@ interface IChangesTreeTemplate { readonly templateDisposables: DisposableStore; readonly toolbar: MenuWorkbenchToolBar | undefined; readonly contextKeyService: IContextKeyService | undefined; + readonly reviewCommentsBadge: HTMLElement; readonly decorationBadge: HTMLElement; readonly addedSpan: HTMLElement; readonly removedSpan: HTMLElement; @@ -846,6 +1077,9 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { @@ -898,6 +1132,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${data.reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + // Update decoration badge (A/M/D) const badge = templateData.decorationBadge; badge.className = 'changes-decoration-badge'; @@ -961,6 +1208,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer { icon: Codicon.listTree, toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.List), menu: { - id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + id: MenuId.ChatEditingSessionTitleToolbar, group: '1_viewmode', order: 1 } @@ -998,7 +1245,7 @@ class SetChangesListViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.List; + view.viewModel.setViewMode(ChangesViewMode.List); } } @@ -1012,8 +1259,7 @@ class SetChangesTreeViewModeAction extends ViewAction { icon: Codicon.listFlat, toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.Tree), menu: { - id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + id: MenuId.ChatEditingSessionTitleToolbar, group: '1_viewmode', order: 2 } @@ -1021,9 +1267,66 @@ class SetChangesTreeViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.Tree; + view.viewModel.setViewMode(ChangesViewMode.Tree); } } registerAction2(SetChangesListViewModeAction); registerAction2(SetChangesTreeViewModeAction); + +// --- Versions Submenu + +MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionTitleToolbar, { + submenu: MenuId.ChatEditingSessionChangesVersionsSubmenu, + title: localize2('versionsActions', 'Versions'), + icon: Codicon.versions, + group: 'navigation', + order: 9, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', CHANGES_VIEW_ID), IsSessionsWindowContext), +}); + +class AllChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsAllChanges', + title: localize2('chatEditing.versionsAllChanges', 'All Changes'), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.AllChanges), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 1, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.viewModel.setVersionMode(ChangesVersionMode.AllChanges); + } +} +registerAction2(AllChangesAction); + +class LastTurnChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsLastTurnChanges', + title: localize2('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.LastTurn), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 2, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.viewModel.setVersionMode(ChangesVersionMode.LastTurn); + } +} +registerAction2(LastTurnChangesAction); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts new file mode 100644 index 0000000000000..42532133a8da1 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, IAction2Options, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; + +import { activeSessionHasChangesContextKey } from '../common/changes.js'; + +const openChangesViewActionOptions: IAction2Options = { + id: 'workbench.action.agentSessions.openChangesView', + title: localize2('openChangesView', "Changes"), + icon: Codicon.diffMultiple, + f1: false, +}; + +class OpenChangesViewAction extends Action2 { + + static readonly ID = openChangesViewActionOptions.id; + + constructor() { + super(openChangesViewActionOptions); + } + + async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + await viewsService.openView(CHANGES_VIEW_ID, true); + } +} + +registerAction2(OpenChangesViewAction); + +class ChangesViewActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.changesViewActions'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + // Bind context key: true when the active session has changes + this._register(bindContextKey(activeSessionHasChangesContextKey, contextKeyService, reader => { + const activeSession = sessionManagementService.activeSession.read(reader); + if (!activeSession) { + return false; + } + const changes = activeSession.changes.read(reader); + return changes.length > 0; + })); + } +} + +registerWorkbenchContribution2(ChangesViewActionsContribution.ID, ChangesViewActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewController.ts b/src/vs/sessions/contrib/changes/browser/changesViewController.ts new file mode 100644 index 0000000000000..4b29423e6491a --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewController.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { autorun, derived } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; + +interface IPendingTurnState { + readonly hadChangesBeforeSend: boolean; + readonly submittedAt: number; +} + +export class ChangesViewController extends Disposable { + + static readonly ID = 'workbench.contrib.changesViewController'; + + private readonly pendingTurnStateByResource = new ResourceMap(); + + constructor( + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IChatService private readonly chatService: IChatService, + @IViewsService private readonly viewsService: IViewsService, + ) { + super(); + + const activeSessionHasChangesObs = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + if (!activeSession) { + return false; + } + const changes = activeSession.changes.read(reader); + return changes.length > 0; + }); + + // Switch between sessions + this._register(autorun(reader => { + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + this.syncAuxiliaryBarVisibility(activeSessionHasChanges); + })); + + // When a turn is completed, check if there were changes before the turn and + // if there are changes after the turn. If there were no changes before the + // turn and there are changes after the turn, show the auxiliary bar. + this._register(autorun((reader) => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + if (!activeSession) { + return; + } + + const pendingTurnState = this.pendingTurnStateByResource.get(activeSession.resource); + if (!pendingTurnState) { + return; + } + + const lastTurnEnd = activeSession.lastTurnEnd.read(reader); + const turnCompleted = !!lastTurnEnd && lastTurnEnd.getTime() >= pendingTurnState.submittedAt; + if (!turnCompleted) { + return; + } + + if (!pendingTurnState.hadChangesBeforeSend && activeSessionHasChanges) { + this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + + this.pendingTurnStateByResource.delete(activeSession.resource); + })); + + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this.pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: activeSessionHasChangesObs.get(), + submittedAt: Date.now(), + }); + })); + } + + private syncAuxiliaryBarVisibility(hasChanges: boolean): void { + if (hasChanges) { + this.viewsService.openView(CHANGES_VIEW_ID, false); + } else { + this.layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + } +} diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts new file mode 100644 index 0000000000000..9b81b8bf26c64 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -0,0 +1,506 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/ciStatusWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; +import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './fixCIChecksAction.js'; + +const $ = dom.$; + +interface ICICheckListItem { + readonly check: IGitHubCICheck; + readonly group: CICheckGroup; +} + +interface ICICheckCounts { + readonly running: number; + readonly pending: number; + readonly failed: number; + readonly successful: number; +} + +class CICheckListDelegate implements IListVirtualDelegate { + static readonly ITEM_HEIGHT = 24; + + getHeight(_element: ICICheckListItem): number { + return CICheckListDelegate.ITEM_HEIGHT; + } + + getTemplateId(_element: ICICheckListItem): string { + return CICheckListRenderer.TEMPLATE_ID; + } +} + +interface ICICheckTemplateData { + readonly row: HTMLElement; + readonly label: IResourceLabel; + readonly actionBar: ActionBar; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +class CICheckListRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'ciCheck'; + readonly templateId = CICheckListRenderer.TEMPLATE_ID; + + constructor( + private readonly _labels: ResourceLabels, + private readonly _openerService: IOpenerService, + ) { } + + renderTemplate(container: HTMLElement): ICICheckTemplateData { + const templateDisposables = new DisposableStore(); + const row = dom.append(container, $('.ci-status-widget-check')); + + const labelContainer = dom.append(row, $('.ci-status-widget-check-label')); + const label = templateDisposables.add(this._labels.create(labelContainer, { supportIcons: true })); + + const actionBarContainer = dom.append(row, $('.ci-status-widget-check-actions')); + const actionBar = templateDisposables.add(new ActionBar(actionBarContainer)); + + return { + row, + label, + actionBar, + templateDisposables, + elementDisposables: templateDisposables.add(new DisposableStore()), + }; + } + + renderElement(element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + + templateData.row.className = `ci-status-widget-check ${getCheckStatusClass(element.check)}`; + + const title = localize('ci.checkTitle', "{0}: {1}", element.check.name, getCheckStateLabel(element.check)); + templateData.label.setResource({ + name: element.check.name, + resource: URI.from({ scheme: 'github-check', path: `/${element.check.id}/${element.check.name}` }), + }, { + icon: getCheckIcon(element.check), + title, + }); + + const actions: Action[] = []; + + if (element.check.detailsUrl) { + actions.push(templateData.elementDisposables.add(new Action( + 'ci.openOnGitHub', + localize('ci.openOnGitHub', "Open on GitHub"), + ThemeIcon.asClassName(Codicon.linkExternal), + true, + async () => { + await this._openerService.open(URI.parse(element.check.detailsUrl!)); + }, + ))); + } + + templateData.actionBar.push(actions, { icon: true, label: false }); + } + + disposeElement(_element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + } + + disposeTemplate(templateData: ICICheckTemplateData): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * A collapsible widget that shows the CI status of a PR. + * Rendered beneath the changes tree in the changes view. + */ +export class CIStatusWidget extends Disposable { + + static readonly HEADER_HEIGHT = 30; + static readonly MIN_BODY_HEIGHT = 72; // at least 3 checks (3 * 24) + static readonly MAX_BODY_HEIGHT = 240; // at most 10 checks (10 * 24) + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabel: IResourceLabel; + private readonly _headerActionBarContainer: HTMLElement; + private readonly _headerActionBar: ActionBar; + private readonly _twistieNode: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _list: WorkbenchList; + private readonly _labels: ResourceLabels; + private readonly _headerActionDisposables = this._register(new DisposableStore()); + + private readonly _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private _collapsed = true; + private _checkCount = 0; + private _model: GitHubPullRequestCIModel | undefined; + private _sessionResource: URI | undefined; + + get element(): HTMLElement { + return this._domNode; + } + + /** The full content height the widget would like (header + all checks). */ + get desiredHeight(): number { + if (this._checkCount === 0) { + return 0; + } + if (this._collapsed) { + return CIStatusWidget.HEADER_HEIGHT; + } + return CIStatusWidget.HEADER_HEIGHT + this._checkCount * CICheckListDelegate.ITEM_HEIGHT; + } + + /** Whether the widget is currently visible (has checks to show). */ + get visible(): boolean { + return this._checkCount > 0; + } + + constructor( + container: HTMLElement, + @IOpenerService private readonly _openerService: IOpenerService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._domNode = dom.append(container, $('.ci-status-widget')); + this._domNode.style.display = 'none'; + + // Header (always visible) + this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title')); + this._titleLabel = this._register(this._labels.create(this._titleNode, { supportIcons: true })); + this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions')); + this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer)); + this._headerActionBarContainer.style.display = 'none'; + this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + })); + this._twistieNode = dom.append(this._headerNode, $('.ci-status-widget-twistie')); + this._updateTwistie(); + + this._register(dom.addDisposableListener(this._headerNode, 'click', () => this._toggle())); + + // Body (collapsible list of checks) + this._bodyNode = dom.append(this._domNode, $('.ci-status-widget-body')); + this._bodyNode.style.display = 'none'; + + const listContainer = $('.ci-status-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList, + 'CIStatusWidget', + listContainer, + new CICheckListDelegate(), + [new CICheckListRenderer(this._labels, this._openerService)], + { + multipleSelectionSupport: false, + openOnSingleClick: false, + accessibilityProvider: { + getWidgetAriaLabel: () => localize('ci.checksListAriaLabel', "Checks"), + getAriaLabel: item => localize('ci.checkAriaLabel', "{0}, {1}", item.check.name, getCheckStateLabel(item.check)), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: item => item.check.name, + }, + }, + )); + this._bodyNode.appendChild(this._list.getHTMLElement()); + } + + /** + * Bind to a CI model. When `ciModel` is undefined, the widget hides. + * Returns a disposable that stops observation. + */ + bind(ciModel: IObservable, sessionResource: IObservable): IDisposable { + return autorun(reader => { + const model = ciModel.read(reader); + this._sessionResource = sessionResource.read(reader); + this._model = model; + if (!model) { + this._checkCount = 0; + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + this._onDidChangeHeight.fire(); + return; + } + + const checks = model.checks.read(reader); + const overallStatus = model.overallStatus.read(reader); + + if (checks.length === 0) { + this._checkCount = 0; + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + this._onDidChangeHeight.fire(); + return; + } + + const sorted = sortChecks(checks); + const oldCount = this._checkCount; + this._checkCount = sorted.length; + + this._domNode.style.display = ''; + this._renderHeader(checks, overallStatus); + this._renderHeaderActions(getFailedChecks(checks)); + this._renderBody(sorted); + + if (this._checkCount !== oldCount) { + this._onDidChangeHeight.fire(); + } + }); + } + + private _toggle(): void { + this._collapsed = !this._collapsed; + this._bodyNode.style.display = this._collapsed ? 'none' : ''; + this._updateTwistie(); + this._onDidChangeHeight.fire(); + } + + private _updateTwistie(): void { + dom.clearNode(this._twistieNode); + this._twistieNode.appendChild(renderIcon(this._collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + } + + private _renderHeader(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): void { + const { icon, className } = getHeaderIconAndClass(checks, overallStatus); + this._titleNode.className = `ci-status-widget-title ${className}`; + + const summary = getChecksSummary(checks); + const title = localize('ci.headerTitle', "Checks: {0}", summary); + this._titleLabel.setResource({ + name: title, + resource: URI.from({ scheme: 'github-checks', path: '/summary' }), + }, { + icon: icon, + title, + }); + } + + private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void { + this._headerActionDisposables.clear(); + this._headerActionBar.clear(); + + if (failedChecks.length === 0) { + this._headerActionBarContainer.style.display = 'none'; + return; + } + + const fixChecksAction = this._headerActionDisposables.add(new Action( + 'ci.fixChecks', + localize('ci.fixChecks', "Fix Checks"), + ThemeIcon.asClassName(Codicon.lightbulbAutofix), + true, + async () => { + await this._sendFixChecksPrompt(failedChecks); + }, + )); + + this._headerActionBar.push([fixChecksAction], { icon: true, label: false }); + this._headerActionBarContainer.style.display = 'flex'; + } + + /** + * Layout the widget body list to the given height. + * Called by the parent view after computing available space. + */ + layout(maxBodyHeight: number): void { + if (this._collapsed || this._checkCount === 0) { + return; + } + const contentHeight = this._checkCount * CICheckListDelegate.ITEM_HEIGHT; + const bodyHeight = Math.min(contentHeight, maxBodyHeight); + this._list.getHTMLElement().style.height = `${bodyHeight}px`; + this._list.layout(bodyHeight); + } + + private _renderBody(checks: readonly ICICheckListItem[]): void { + const contentHeight = checks.length * CICheckListDelegate.ITEM_HEIGHT; + const bodyHeight = Math.min(contentHeight, CIStatusWidget.MAX_BODY_HEIGHT); + this._list.getHTMLElement().style.height = `${bodyHeight}px`; + this._list.layout(bodyHeight); + this._list.splice(0, this._list.length, checks); + } + + private async _sendFixChecksPrompt(failedChecks: readonly IGitHubCICheck[]): Promise { + const model = this._model; + const sessionResource = this._sessionResource; + if (!model || !sessionResource || failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await model.getCheckRunAnnotations(check.id); + return { + check, + annotations, + }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = this._chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +function sortChecks(checks: readonly IGitHubCICheck[]): ICICheckListItem[] { + return [...checks] + .sort(compareChecks) + .map(check => ({ check, group: getCheckGroup(check) })); +} + +function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number { + const groupDiff = getCheckGroup(a) - getCheckGroup(b); + if (groupDiff !== 0) { + return groupDiff; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} + +function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { + let running = 0; + let pending = 0; + let failed = 0; + let successful = 0; + + for (const check of checks) { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + running++; + break; + case CICheckGroup.Pending: + pending++; + break; + case CICheckGroup.Failed: + failed++; + break; + case CICheckGroup.Successful: + successful++; + break; + } + } + + return { running, pending, failed, successful }; +} + +function getChecksSummary(checks: readonly IGitHubCICheck[]): string { + const counts = getCheckCounts(checks); + const parts: string[] = []; + + if (counts.running > 0) { + parts.push(counts.running === 1 + ? localize('ci.oneRunning', "1 running") + : localize('ci.manyRunning', "{0} running", counts.running)); + } + + if (counts.pending > 0) { + parts.push(counts.pending === 1 + ? localize('ci.onePending', "1 pending") + : localize('ci.manyPending', "{0} pending", counts.pending)); + } + + if (counts.failed > 0) { + parts.push(counts.failed === 1 + ? localize('ci.oneFailed', "1 failed") + : localize('ci.manyFailed', "{0} failed", counts.failed)); + } + + if (counts.successful > 0) { + parts.push(counts.successful === 1 + ? localize('ci.oneSuccessful', "1 successful") + : localize('ci.manySuccessful', "{0} successful", counts.successful)); + } + + return parts.join(', '); +} + +function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): { icon: ThemeIcon; className: string } { + const counts = getCheckCounts(checks); + if (counts.running > 0) { + return { icon: Codicon.clock, className: 'ci-status-running' }; + } + + switch (overallStatus) { + case GitHubCIOverallStatus.Success: + return { icon: Codicon.passFilled, className: 'ci-status-success' }; + case GitHubCIOverallStatus.Failure: + return { icon: Codicon.error, className: 'ci-status-failure' }; + case GitHubCIOverallStatus.Pending: + return { icon: Codicon.circleFilled, className: 'ci-status-pending' }; + default: + return { icon: Codicon.circleFilled, className: 'ci-status-neutral' }; + } +} + +function getCheckIcon(check: IGitHubCICheck): ThemeIcon { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return Codicon.clock; + case GitHubCheckStatus.Queued: + return Codicon.circleFilled; + case GitHubCheckStatus.Completed: + switch (check.conclusion) { + case GitHubCheckConclusion.Success: + return Codicon.passFilled; + case GitHubCheckConclusion.Failure: + case GitHubCheckConclusion.TimedOut: + case GitHubCheckConclusion.ActionRequired: + return Codicon.error; + case GitHubCheckConclusion.Cancelled: + return Codicon.circleSlash; + case GitHubCheckConclusion.Skipped: + return Codicon.debugStepOver; + default: + return Codicon.circleFilled; + } + default: + return Codicon.circleFilled; + } +} + +function getCheckStatusClass(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return 'ci-status-running'; + case CICheckGroup.Pending: + return 'ci-status-pending'; + case CICheckGroup.Failed: + return 'ci-status-failure'; + case CICheckGroup.Successful: + return 'ci-status-success'; + } +} diff --git a/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts new file mode 100644 index 0000000000000..dbaa2ceee6300 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +export const hasActiveSessionFailedCIChecks = new RawContextKey('sessions.hasActiveSessionFailedCIChecks', false); + +// --- Shared CI check utilities ------------------------------------------------ + +export const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +export function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} + +export function getCheckGroup(check: IGitHubCICheck): CICheckGroup { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return CICheckGroup.Running; + case GitHubCheckStatus.Queued: + return CICheckGroup.Pending; + case GitHubCheckStatus.Completed: + return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; + } +} + +export function getCheckStateLabel(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return localize('ci.runningState', "running"); + case CICheckGroup.Pending: + return localize('ci.pendingState', "pending"); + case CICheckGroup.Failed: + return localize('ci.failedState', "failed"); + case CICheckGroup.Successful: + return localize('ci.successfulState', "successful"); + } +} + +export function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +export function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { + const sections = failedChecks.map(({ check, annotations }) => { + const parts = [ + `Check: ${check.name}`, + `Status: ${getCheckStateLabel(check)}`, + `Conclusion: ${check.conclusion ?? 'unknown'}`, + ]; + + if (check.detailsUrl) { + parts.push(`Details: ${check.detailsUrl}`); + } + + parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); + return parts.join('\n'); + }); + + return [ + 'Please fix the failed CI checks for this session immediately.', + 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', + 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', + '', + 'Failed CI checks:', + '', + sections.join('\n\n---\n\n'), + ].join('\n'); +} + +/** + * Sets the `hasActiveSessionFailedCIChecks` context key to true when the + * active session has a PR with CI checks and at least one has failed. + */ +class ActiveSessionFailedCIChecksContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFailedCIChecksContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IGitHubService gitHubService: IGitHubService, + ) { + super(); + + const ciModelObs = derived(this, reader => { + const session = sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + return gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + }); + + this._register(bindContextKey(hasActiveSessionFailedCIChecks, contextKeyService, reader => { + const ciModel = ciModelObs.read(reader); + if (!ciModel) { + return false; + } + const checks = ciModel.checks.read(reader); + return getFailedChecks(checks).length > 0; + })); + } +} + +class FixCIChecksAction extends Action2 { + + static readonly ID = 'sessions.action.fixCIChecks'; + + constructor() { + super({ + id: FixCIChecksAction.ID, + title: localize2('fixCIChecks', 'Fix CI Checks'), + icon: Codicon.lightbulbAutofix, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionFailedCIChecks), + menu: [{ + id: MenuId.ChatEditingSessionApplySubmenu, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionFailedCIChecks), + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const gitHubService = accessor.get(IGitHubService); + const chatWidgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.activeSession.get(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const context = sessionManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.get(); + if (!pr) { + return; + } + + const ciModel = gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + const checks = ciModel.checks.get(); + const failedChecks = getFailedChecks(checks); + if (failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await ciModel.getCheckRunAnnotations(check.id); + return { check, annotations }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + logService.error('[FixCIChecks] Cannot fix CI checks: no chat widget found for session', sessionResource.toString()); + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +registerWorkbenchContribution2(ActiveSessionFailedCIChecksContextContribution.ID, ActiveSessionFailedCIChecksContextContribution, WorkbenchPhase.AfterRestored); +registerAction2(FixCIChecksAction); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css new file mode 100644 index 0000000000000..a8a456ab4aaa9 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Changes Titlebar Indicator (in right toolbar) ---- */ + +.agent-sessions-workbench .changes-titlebar-indicator { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 4px; + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + color: inherit; + gap: 3px; +} + +.agent-sessions-workbench .changes-titlebar-indicator:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .changes-titlebar-indicator.active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .changes-titlebar-indicator .codicon { + font-size: 16px; + color: inherit; +} + +.agent-sessions-workbench .changes-titlebar-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: inherit; +} + +.agent-sessions-workbench .changes-titlebar-insertions { + color: var(--vscode-gitDecoration-addedResourceForeground); + font-weight: 600; +} + +.agent-sessions-workbench .changes-titlebar-deletions { + color: var(--vscode-gitDecoration-deletedResourceForeground); + font-weight: 600; +} diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css similarity index 83% rename from src/vs/sessions/contrib/changesView/browser/media/changesView.css rename to src/vs/sessions/contrib/changes/browser/media/changesView.css index 1300b886cbcd8..ce34dce6c7cb3 100644 --- a/src/vs/sessions/contrib/changesView/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -104,7 +104,6 @@ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button { height: 26px; padding: 4px 14px; - border-radius: 4px; font-size: 12px; line-height: 18px; } @@ -114,6 +113,29 @@ flex: 1; } +/* ButtonWithDropdown container grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown { + flex: 1; + display: flex; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button { + flex: 1; + box-sizing: border-box; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button-dropdown-separator { + flex: 0; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + flex: 0 0 auto; + padding: 4px; + width: auto; + min-width: 0; + border-radius: 0px 4px 4px 0px; +} + .changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { padding: 4px 8px; font-size: 16px !important; @@ -136,6 +158,10 @@ color: var(--vscode-button-secondaryForeground); } +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { + border-radius: 4px 0px 0px 4px; +} + .changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { background-color: var(--vscode-button-secondaryHoverBackground); color: var(--vscode-button-secondaryForeground); @@ -213,6 +239,19 @@ font-size: 11px; } +.changes-view-body .chat-editing-session-list .changes-review-comments-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + margin-right: 6px; + color: var(--vscode-descriptionForeground); +} + +.changes-view-body .chat-editing-session-list .changes-review-comments-badge .codicon { + font-size: 12px; +} + .changes-view-body .chat-editing-session-list .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); } @@ -221,17 +260,8 @@ color: var(--vscode-chat-linesRemovedForeground); } -/* Line counts in buttons */ -.changes-view-body .chat-editing-session-actions .monaco-button.working-set-diff-stats { - flex-shrink: 0; +.changes-view-body .chat-editing-session-actions .monaco-button.code-review-comments, +.changes-view-body .chat-editing-session-actions .monaco-button.code-review-loading { padding-left: 4px; - padding-right: 8px; -} - -.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { - color: var(--vscode-chat-linesRemovedForeground); + padding-right: 4px; } diff --git a/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css b/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css new file mode 100644 index 0000000000000..b848ca2f2d161 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.changes-action-view-item { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + padding: 0 4px; + border-radius: 3px; +} + +.changes-action-view-item:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.changes-action-view-item .changes-action-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 14px; +} + +.changes-action-view-item .changes-action-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.changes-action-view-item .changes-action-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} diff --git a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css new file mode 100644 index 0000000000000..451457dd54385 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* CI Status Widget - beneath the changes tree */ +.ci-status-widget { + margin-top: 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + background-color: var(--vscode-editor-background); + overflow: hidden; + font-size: 12px; +} + +/* Header - always visible, clickable */ +.ci-status-widget-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + min-height: 22px; +} + +.ci-status-widget-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Title - single line, overflow ellipsis */ +.ci-status-widget-title { + flex: 1; + display: flex; + align-items: center; + overflow: hidden; + color: var(--vscode-foreground); +} + +.ci-status-widget-title .monaco-icon-label { + width: 100%; + height: 18px; +} + +.ci-status-widget-title .monaco-icon-label-container, +.ci-status-widget-title .monaco-icon-name-container { + display: block; + overflow: hidden; +} + +.ci-status-widget-title .label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ci-status-widget-header-actions { + flex: 0 0 auto; + display: none; + align-items: center; + margin-left: auto; +} + +.ci-status-widget-header-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-header-actions .action-item .action-label { + width: 16px; + height: 16px; +} + +/* Twistie icon on the right */ +.ci-status-widget-twistie { + flex: 0 0 auto; + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +/* Body - collapsible list */ +.ci-status-widget-body { + border-top: 1px solid var(--vscode-input-border, transparent); +} + +.ci-status-widget-list { + background-color: transparent; +} + +.ci-status-widget-list > .monaco-list, +.ci-status-widget-list > .monaco-list > .monaco-scrollable-element { + background-color: transparent; +} + +/* Individual check row */ +.ci-status-widget-check { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check { + background-color: var(--vscode-list-hoverBackground); +} + +.ci-status-widget-check-label { + display: flex; + flex: 1; + min-width: 0; + overflow: hidden; +} + + +.ci-status-widget-check-label .monaco-icon-label { + display: flex; + flex: 1; + min-width: 0; + width: 100%; +} + +.ci-status-widget-check-label .monaco-icon-label-container, +.ci-status-widget-check-label .monaco-icon-name-container { + display: block; + min-width: 0; + overflow: hidden; +} + +.ci-status-widget-check-label .label-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); +} + +.ci-status-widget-title.ci-status-success .monaco-icon-label::before, +.ci-status-widget-check.ci-status-success .monaco-icon-label::before { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.ci-status-widget-title.ci-status-failure .monaco-icon-label::before, +.ci-status-widget-check.ci-status-failure .monaco-icon-label::before { + color: var(--vscode-testing-iconFailed, #f14c4c); +} + +.ci-status-widget-title.ci-status-running .monaco-icon-label::before, +.ci-status-widget-check.ci-status-running .monaco-icon-label::before, +.ci-status-widget-title.ci-status-pending .monaco-icon-label::before { + color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground)); +} + +.ci-status-widget-check.ci-status-pending .monaco-icon-label::before { + color: var(--vscode-descriptionForeground); +} + +.ci-status-widget-title.ci-status-neutral .monaco-icon-label::before, +.ci-status-widget-check.ci-status-neutral .monaco-icon-label::before { + color: var(--vscode-descriptionForeground); +} + +/* Actions - float to the right, visible on hover */ +.ci-status-widget-check-actions { + display: none; + flex: 0 0 auto; + flex-shrink: 0; + margin-left: auto; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check-actions, +.ci-status-widget-check:hover .ci-status-widget-check-actions { + display: flex; +} + +.ci-status-widget-check-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-check-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} diff --git a/extensions/git/extension.webpack.config.js b/src/vs/sessions/contrib/changes/common/changes.ts similarity index 59% rename from extensions/git/extension.webpack.config.js rename to src/vs/sessions/contrib/changes/common/changes.ts index 34f801e2eca4e..23c69cd418217 100644 --- a/extensions/git/extension.webpack.config.js +++ b/src/vs/sessions/contrib/changes/common/changes.ts @@ -2,16 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - ['askpass-main']: './src/askpass-main.ts', - ['git-editor-main']: './src/git-editor-main.ts' - } -}); +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -export const StripOutSourceMaps = ['dist/askpass-main.js']; +export const activeSessionHasChangesContextKey = new RawContextKey('activeSessionHasChanges', false, localize('activeSessionHasChanges', "Whether the active session has changes.")); diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 32d2236832b09..169051def86aa 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -3,37 +3,88 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { derived, IObservable } from '../../../../base/common/observable.js'; +import { derived, IObservable, observableValue, ISettableObservable } from '../../../../base/common/observable.js'; +import { relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { localize } from '../../../../nls.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. * Delegates to ISessionsManagementService to provide the active session's * worktree/repository as the project root, and supports worktree commit. + * + * Customization files are always committed to the main repository so they + * persist across worktrees. When a worktree is active the file is also + * copied into the worktree and committed there so the running session + * picks it up immediately. */ export class SessionsAICustomizationWorkspaceService implements IAICustomizationWorkspaceService { declare readonly _serviceBrand: undefined; readonly activeProjectRoot: IObservable; + readonly hasOverrideProjectRoot: IObservable; + + /** + * Transient override for the project root. When set, `activeProjectRoot` + * returns this value instead of the session-derived root. + */ + private readonly _overrideRoot: ISettableObservable; constructor( @ISessionsManagementService private readonly sessionsService: ISessionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPromptsService private readonly promptsService: IPromptsService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @IFileService private readonly fileService: IFileService, + @INotificationService private readonly notificationService: INotificationService, ) { + this._overrideRoot = observableValue(this, undefined); + this.activeProjectRoot = derived(reader => { + const override = this._overrideRoot.read(reader); + if (override) { + return override; + } const session = this.sessionsService.activeSession.read(reader); - return session?.worktree ?? session?.repository; + const repo = session?.workspace.read(reader)?.repositories[0]; + return repo?.workingDirectory ?? repo?.uri; + }); + + this.hasOverrideProjectRoot = derived(reader => { + return this._overrideRoot.read(reader) !== undefined; }); } getActiveProjectRoot(): URI | undefined { - const session = this.sessionsService.getActiveSession(); - return session?.worktree ?? session?.repository; + const override = this._overrideRoot.get(); + if (override) { + return override; + } + const session = this.sessionsService.activeSession.get(); + const repo = session?.workspace.get()?.repositories[0]; + return repo?.workingDirectory ?? repo?.uri; + } + + setOverrideProjectRoot(root: URI): void { + this._overrideRoot.set(root, undefined); + } + + clearOverrideProjectRoot(): void { + this._overrideRoot.set(undefined, undefined); } readonly managementSections: readonly AICustomizationManagementSection[] = [ @@ -43,15 +94,158 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Models, + AICustomizationManagementSection.Plugins, ]; - readonly preferManualCreation = true; + getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { + return this.harnessService.getStorageSourceFilter(type); + } + + readonly isSessionsWindow = true; + + /** + * Commits customization files. Always commits to the main repository + * so the change persists across worktrees. When a worktree is active + * the file is also committed there so the session sees it immediately. + */ + async commitFiles(_projectRoot: URI, fileUris: URI[]): Promise { + const session = this.sessionsService.activeSession.get(); + const repo = session?.workspace.get()?.repositories[0]; + if (!repo?.uri) { + return; + } + + for (const fileUri of fileUris) { + await this.commitFileToRepos(fileUri, repo.uri, repo.workingDirectory); + } + } + + /** + * Commits the deletion of files that have already been removed from disk. + * Always stages + commits the removal in the main repository, and also + * in the worktree if one is active. + */ + async deleteFiles(_projectRoot: URI, fileUris: URI[]): Promise { + const session = this.sessionsService.activeSession.get(); + const repo = session?.workspace.get()?.repositories[0]; + if (!repo?.uri) { + return; + } + + for (const fileUri of fileUris) { + await this.commitDeletionToRepos(fileUri, repo.uri, repo.workingDirectory); + } + } + + /** + * Computes the repository-relative path for a file. The file may be + * located under the worktree or the repository root. + */ + private getRelativePath(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): string | undefined { + // Try worktree first (when active, files are written under it) + if (worktreeUri) { + const rel = relativePath(worktreeUri, fileUri); + if (rel) { + return rel; + } + } + return relativePath(repositoryUri, fileUri); + } + + /** + * Commits a single file to the main repository and optionally the worktree. + * Copies the file content between trees when needed. + */ + private async commitFileToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); + + // 1. Always commit to main repository + try { + if (repoFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(repoFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('commitToRepoFailed', "Your customization was saved to this session's worktree, but we couldn't apply it to the default branch. You may need to apply it manually."), + }); + } + } + + // 2. Also commit to the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + if (worktreeFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(worktreeFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to worktree:', error); + } + } + } + + /** + * Commits the deletion of a file to the main repository and optionally + * the worktree. The file is already deleted from disk before this is called; + * `git add` on a deleted path stages the removal. + */ + private async commitDeletionToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); - async commitFiles(projectRoot: URI, fileUris: URI[]): Promise { - const session = this.sessionsService.getActiveSession(); - if (session) { - await this.sessionsService.commitWorktreeFiles(session, fileUris); + // 1. Delete from main repository if it exists there, then commit + try { + if (await this.fileService.exists(repoFileUri)) { + await this.fileService.del(repoFileUri, { useTrash: true, recursive: true }); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('deleteFromRepoFailed', "Your customization was removed from this session's worktree, but we couldn't apply the change to the default branch. You may need to remove it manually."), + }); + } + } + + // 2. Also commit the deletion in the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + // The file may already be deleted from the worktree by the caller + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to worktree:', error); + } } } @@ -59,4 +253,12 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization const creator = this.instantiationService.createInstance(CustomizationCreatorService); await creator.createWithAI(type); } + + async getFilteredPromptSlashCommands(token: CancellationToken): Promise { + const allCommands = await this.promptsService.getPromptSlashCommands(token); + return allCommands.filter(cmd => { + const filter = this.getStorageSourceFilter(cmd.promptPath.type); + return applyStorageSourceFilter([cmd.promptPath], filter).length > 0; + }); + } } diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index 7744e54a3dacf..a4a092d83492f 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -2,210 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import * as dom from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { INewSession } from './newSession.js'; - -const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; -const FILTER_THRESHOLD = 10; - -interface IBranchItem { - readonly name: string; -} - -/** - * A self-contained widget for selecting a git branch. - * Uses `IGitRepository.getRefs` to list local branches. - * Copilot worktree branches are shown in a collapsible section; - * other branches are listed without a section header. - * Writes the selected branch to the new session object. - */ -export class BranchPicker extends Disposable { - - private _selectedBranch: string | undefined; - private _newSession: INewSession | undefined; - private _branches: string[] = []; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _onDidChangeLoading = this._register(new Emitter()); - readonly onDidChangeLoading: Event = this._onDidChangeLoading.event; - - private readonly _renderDisposables = this._register(new DisposableStore()); - private _slotElement: HTMLElement | undefined; - private _triggerElement: HTMLElement | undefined; - - get selectedBranch(): string | undefined { - return this._selectedBranch; - } - - constructor( - @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, - ) { - super(); - } - - /** - * Sets the new session that this picker writes to. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - - /** - * Sets the git repository and loads its branches. - * When undefined, the picker is shown disabled. - */ - async setRepository(repository: IGitRepository | undefined): Promise { - this._branches = []; - this._selectedBranch = undefined; - - if (!repository) { - this._newSession?.setBranch(undefined); - this._setLoading(false); - this._updateTriggerLabel(); - return; - } - - this._setLoading(true); - - try { - const refs = await repository.getRefs({ pattern: 'refs/heads' }); - this._branches = refs - .map(ref => ref.name) - .filter((name): name is string => !!name) - .filter(name => !name.includes(COPILOT_WORKTREE_PATTERN)); - - // Select active branch, main, master, or the first branch by default - const defaultBranch = this._branches.find(b => b === repository.state.get().HEAD?.name) - ?? this._branches.find(b => b === 'main') - ?? this._branches.find(b => b === 'master') - ?? this._branches[0]; - if (defaultBranch) { - this._selectBranch(defaultBranch); - } - } finally { - this._setLoading(false); - this._updateTriggerLabel(); - } - } - - /** - * Renders the branch picker trigger into the given container. - */ - render(container: HTMLElement): void { - this._renderDisposables.clear(); - - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); - this._slotElement = slot; - this._renderDisposables.add({ dispose: () => slot.remove() }); - - const trigger = dom.append(slot, dom.$('a.action-label')); - trigger.tabIndex = 0; - trigger.role = 'button'; - this._triggerElement = trigger; - this._updateTriggerLabel(); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { - dom.EventHelper.stop(e, true); - this.showPicker(); - })); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { - if (e.key === 'Enter' || e.key === ' ') { - dom.EventHelper.stop(e, true); - this.showPicker(); - } - })); - } - - /** - * Shows or hides the picker. - */ - setVisible(visible: boolean): void { - if (this._slotElement) { - this._slotElement.style.display = visible ? '' : 'none'; - } - } - - /** - * Shows the branch picker dropdown anchored to the trigger element. - */ - showPicker(): void { - if (!this._triggerElement || this.actionWidgetService.isVisible || this._branches.length === 0) { - return; - } - - const items = this._buildItems(); - const triggerElement = this._triggerElement; - const delegate: IActionListDelegate = { - onSelect: (item) => { - this.actionWidgetService.hide(); - this._selectBranch(item.name); - }, - onHide: () => { triggerElement.focus(); }, - }; - - const totalActions = items.filter(i => i.kind === ActionListItemKind.Action).length; - - this.actionWidgetService.show( - 'branchPicker', - false, - items, - delegate, - this._triggerElement, - undefined, - [], - { - getAriaLabel: (item) => item.label ?? '', - getWidgetAriaLabel: () => localize('branchPicker.ariaLabel', "Branch Picker"), - }, - totalActions > FILTER_THRESHOLD ? { showFilter: true, filterPlaceholder: localize('branchPicker.filter', "Filter branches...") } : undefined, - ); - } - - private _buildItems(): IActionListItem[] { - return this._branches.map(branch => ({ - kind: ActionListItemKind.Action, - label: branch, - group: { title: '', icon: this._selectedBranch === branch ? Codicon.check : Codicon.blank }, - item: { name: branch }, - })); - } - - private _selectBranch(branch: string): void { - if (this._selectedBranch !== branch) { - this._selectedBranch = branch; - this._newSession?.setBranch(branch); - this._onDidChange.fire(branch); - this._updateTriggerLabel(); - } - } - - private _updateTriggerLabel(): void { - if (!this._triggerElement) { - return; - } - dom.clearNode(this._triggerElement); - const isDisabled = this._branches.length === 0; - const label = this._selectedBranch ?? localize('branchPicker.select', "Branch"); - dom.append(this._triggerElement, renderIcon(Codicon.gitBranch)); - const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); - labelSpan.textContent = label; - dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - this._slotElement?.classList.toggle('disabled', isDisabled); - } - - private _setLoading(loading: boolean): void { - this._onDidChangeLoading.fire(loading); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index b7879423464a0..91a82b13dfc91 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -17,8 +17,7 @@ import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensi import { Registry } from '../../../../platform/registry/common/platform.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; +import { IsActiveSessionBackgroundProviderContext, ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; import { Menus } from '../../../browser/menus.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; @@ -29,7 +28,9 @@ import { AgenticPromptsService } from './promptsService.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsConfigurationService, SessionsConfigurationService } from './sessionsConfigurationService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { SessionsAICustomizationWorkspaceService } from './aiCustomizationWorkspaceService.js'; +import { SessionsCustomizationHarnessService } from './customizationHarnessService.js'; import { ChatViewContainerId, ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; @@ -37,6 +38,8 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; export class OpenSessionWorktreeInVSCodeAction extends Action2 { static readonly ID = 'chat.openSessionWorktreeInVSCode'; @@ -46,11 +49,12 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { id: OpenSessionWorktreeInVSCodeAction.ID, title: localize2('openInVSCode', 'Open in VS Code'), icon: Codicon.vscodeInsiders, + precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarRightLayout, group: 'navigation', - order: 10, - when: IsAuxiliaryWindowContext.toNegated() + order: 0, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); } @@ -65,7 +69,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { return; } - const folderUri = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud ? activeSession.worktree : undefined; + const workspace = activeSession.workspace.get(); + const repo = workspace?.repositories[0]; + const folderUri = activeSession.sessionType === AgentSessionProviders.Background ? repo?.workingDirectory ?? repo?.uri : undefined; if (!folderUri) { return; @@ -137,11 +143,11 @@ class RegisterChatViewContainerContribution implements IWorkbenchContribution { const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); let chatViewContainer = viewContainerRegistry.get(ChatViewContainerId); if (chatViewContainer) { - viewContainerRegistry.deregisterViewContainer(chatViewContainer); const view = viewsRegistry.getView(ChatViewId); if (view) { viewsRegistry.deregisterViews([view], chatViewContainer); } + viewContainerRegistry.deregisterViewContainer(chatViewContainer); } chatViewContainer = viewContainerRegistry.registerViewContainer({ @@ -193,3 +199,4 @@ registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); registerSingleton(ISessionsConfigurationService, SessionsConfigurationService, InstantiationType.Delayed); registerSingleton(IAICustomizationWorkspaceService, SessionsAICustomizationWorkspaceService, InstantiationType.Delayed); +registerSingleton(ICustomizationHarnessService, SessionsCustomizationHarnessService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts new file mode 100644 index 0000000000000..c11d8ade17137 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/customizationHarnessService.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + CustomizationHarness, + CustomizationHarnessServiceBase, + createCliHarnessDescriptor, + getCliUserRoots, +} from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { BUILTIN_STORAGE } from '../common/builtinPromptsStorage.js'; + +/** + * Sessions-window override of the customization harness service. + * + * Only the CLI harness is registered because sessions always run via + * the Copilot CLI. With a single harness the toggle bar is hidden. + */ +export class SessionsCustomizationHarnessService extends CustomizationHarnessServiceBase { + constructor( + @IPathService pathService: IPathService, + ) { + const userHome = pathService.userHome({ preferLocal: true }); + const extras = [BUILTIN_STORAGE]; + super( + [createCliHarnessDescriptor(getCliUserRoots(userHome), extras)], + CustomizationHarness.CLI, + ); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts new file mode 100644 index 0000000000000..fcda250ebc96c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; + +const PROMPT_SECTIONS: { section: AICustomizationManagementSection; type: PromptsType }[] = [ + { section: AICustomizationManagementSection.Agents, type: PromptsType.agent }, + { section: AICustomizationManagementSection.Skills, type: PromptsType.skill }, + { section: AICustomizationManagementSection.Instructions, type: PromptsType.instructions }, + { section: AICustomizationManagementSection.Prompts, type: PromptsType.prompt }, + { section: AICustomizationManagementSection.Hooks, type: PromptsType.hook }, +]; + +class CustomizationsDebugLogContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.customizationsDebugLog'; + + private readonly _logger: ILogger; + + constructor( + @ILoggerService loggerService: ILoggerService, + @IPromptsService private readonly _promptsService: IPromptsService, + @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IMcpService private readonly _mcpService: IMcpService, + ) { + super(); + this._logger = this._register(loggerService.createLogger('customizationsDebug', { name: 'Customizations Debug' })); + + this._register(this._promptsService.onDidChangeCustomAgents(() => this._logSnapshot())); + this._register(this._promptsService.onDidChangeSlashCommands(() => this._logSnapshot())); + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._logSnapshot())); + this._register(autorun(reader => { + this._workspaceService.activeProjectRoot.read(reader); + this._logSnapshot(); + })); + this._register(autorun(reader => { + this._mcpService.servers.read(reader); + this._logSnapshot(); + })); + } + + private _pendingSnapshot: Promise | undefined; + private _snapshotDirty = false; + + private _logSnapshot(): void { + if (this._pendingSnapshot) { + this._snapshotDirty = true; + return; + } + this._pendingSnapshot = this._doLogSnapshot().finally(() => { + this._pendingSnapshot = undefined; + if (this._snapshotDirty) { + this._snapshotDirty = false; + this._logSnapshot(); + } + }); + } + + private async _doLogSnapshot(): Promise { + const root = this._workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'; + + this._logger.info(''); + this._logger.info('=== Customizations Snapshot ==='); + this._logger.info(` Root: ${root}`); + this._logger.info(` Sections: ${this._workspaceService.managementSections.join(', ')}`); + this._logger.info(''); + + // Header + this._logger.info(` ${'Section'.padEnd(16)} ${'Local'.padStart(6)} ${'User'.padStart(6)} ${'Ext'.padStart(6)} ${'Total'.padStart(7)}`); + this._logger.info(` ${'--------'.padEnd(16)} ${'-----'.padStart(6)} ${'----'.padStart(6)} ${'---'.padStart(6)} ${'-----'.padStart(7)}`); + + for (const { section, type } of PROMPT_SECTIONS) { + const filter = this._workspaceService.getStorageSourceFilter(type); + await this._logSectionRow(section, type, filter); + } + + this._logger.info(''); + + // Details per section + for (const { section, type } of PROMPT_SECTIONS) { + const filter = this._workspaceService.getStorageSourceFilter(type); + await this._logSectionDetails(section, type, filter); + } + + // MCP Servers + this._logMcpServers(); + } + + private _logMcpServers(): void { + const servers = this._mcpService.servers.get(); + this._logger.info(` -- MCP Servers (${servers.length}) --`); + if (servers.length === 0) { + this._logger.info(' (none registered)'); + } + for (const server of servers) { + const state = server.connectionState.get(); + const stateStr = state?.state ?? 'unknown'; + this._logger.info(` ${server.definition.label} [${stateStr}] id=${server.definition.id}`); + } + this._logger.info(''); + } + + private async _logSectionRow(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise { + try { + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.local, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.user, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), + ]); + const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; + const filtered = applyStorageSourceFilter(all, filter); + const local = filtered.filter(f => f.storage === PromptsStorage.local).length; + const user = filtered.filter(f => f.storage === PromptsStorage.user).length; + const ext = filtered.filter(f => f.storage === PromptsStorage.extension).length; + + this._logger.info(` ${section.padEnd(16)} ${String(local).padStart(6)} ${String(user).padStart(6)} ${String(ext).padStart(6)} ${String(filtered.length).padStart(7)}`); + } catch { + this._logger.info(` ${section.padEnd(16)} (error)`); + } + } + + private async _logSectionDetails(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise { + try { + // Source folders - where we look for files + const sourceFolders = await this._promptsService.getSourceFolders(type); + if (sourceFolders.length > 0) { + this._logger.info(` -- ${section} --`); + this._logger.info(` Search paths:`); + for (const sf of sourceFolders) { + this._logger.info(` [${sf.storage}] ${sf.uri.fsPath}`); + } + } + + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.local, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.user, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), + ]); + const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; + const filtered = applyStorageSourceFilter(all, filter); + + if (filtered.length > 0) { + if (sourceFolders.length === 0) { + this._logger.info(` -- ${section} --`); + } + this._logger.info(` Filter: sources=[${filter.sources.join(', ')}]${filter.includedUserFileRoots ? `, roots=[${filter.includedUserFileRoots.map(r => r.fsPath).join(', ')}]` : ''}`); + this._logger.info(` Found ${filtered.length} item(s):`); + for (const f of filtered) { + this._logger.info(` [${f.storage}] ${f.uri.fsPath}`); + } + } + + if (sourceFolders.length > 0 || filtered.length > 0) { + this._logger.info(''); + } + } catch { + // already logged in row + } + } +} + +registerWorkbenchContribution2( + CustomizationsDebugLogContribution.ID, + CustomizationsDebugLogContribution, + WorkbenchPhase.AfterRestored, +); diff --git a/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts b/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts new file mode 100644 index 0000000000000..769ecae8d4c05 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/extensionToolbarPickers.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; +import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; +import { IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionOptionGroup } from './newSession.js'; +import { RemoteNewSession } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +/** + * Self-contained widget that renders extension-driven toolbar pickers + * for Cloud/Remote sessions. Observes the active session from + * {@link ISessionsProvidersService} and dynamically creates/destroys + * picker widgets as option groups change. + */ +export class ExtensionToolbarPickers extends Disposable { + + private _container: HTMLElement | undefined; + private readonly _pickerWidgets = new Map(); + private readonly _pickerDisposables = this._register(new DisposableStore()); + private readonly _optionEmitters = new Map>(); + private readonly _optionContextKeys = new Map>(); + private readonly _sessionDisposables = this._register(new DisposableStore()); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + // Observe active session and render toolbar pickers when it has option groups + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + this._sessionDisposables.clear(); + + if (session) { + // The session data is directly a RemoteNewSession — access its option groups. + this._bindToSession(); + } else { + this._clearPickers(); + } + })); + } + + /** + * Sets the container element where toolbar pickers will be rendered. + */ + setContainer(container: HTMLElement): void { + this._container = container; + } + + private _bindToSession(): void { + // Get the current new session from the provider's internal state + const providers = this.sessionsProvidersService.getProviders(); + for (const provider of providers) { + // eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any + const currentSession = (provider as any)._currentNewSession; + if (currentSession instanceof RemoteNewSession) { + this._renderToolbarPickers(currentSession, true); + this._sessionDisposables.add(currentSession.onDidChangeOptionGroups(() => { + this._renderToolbarPickers(currentSession); + })); + return; + } + } + } + + private _renderToolbarPickers(session: RemoteNewSession, force?: boolean): void { + if (!this._container) { + return; + } + + const toolbarOptions = session.getOtherOptionGroups(); + const visibleGroups = toolbarOptions.filter(option => { + const group = option.group; + return group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + }); + + if (visibleGroups.length === 0) { + this._clearPickers(); + return; + } + + if (!force) { + const allMatch = visibleGroups.length === this._pickerWidgets.size + && visibleGroups.every(o => this._pickerWidgets.has(o.group.id)); + if (allMatch) { + return; + } + } + + this._clearPickers(); + + for (const option of visibleGroups) { + this._renderPickerWidget(option, session); + } + } + + private _renderPickerWidget(option: ISessionOptionGroup, session: RemoteNewSession): void { + const { group: optionGroup, value: initialItem } = option; + + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); + } + + const initialState = { group: optionGroup, item: initialItem }; + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => session.getOptionValue(optionGroup.id) ?? initialItem, + onDidChangeOption: emitter.event, + setOption: (item: IChatSessionProviderOptionItem) => { + this._updateOptionContextKey(optionGroup.id, item.id); + emitter.fire(item); + session.setOptionValue(optionGroup.id, item); + }, + getOptionGroup: () => { + const modelOpt = session.getModelOptionGroup(); + if (modelOpt?.group.id === optionGroup.id) { + return modelOpt.group; + } + return session.getOtherOptionGroups().find(o => o.group.id === optionGroup.id)?.group; + }, + getSessionResource: () => session.resource, + }; + + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate, undefined + ); + + this._pickerDisposables.add(widget); + this._pickerWidgets.set(optionGroup.id, widget); + + const slot = dom.append(this._container!, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); + } + + private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { + let contextKey = this._optionContextKeys.get(optionGroupId); + if (!contextKey) { + const rawKey = new RawContextKey(`chatSessionOption.${optionGroupId}`, ''); + contextKey = rawKey.bindTo(this.contextKeyService); + this._optionContextKeys.set(optionGroupId, contextKey); + } + contextKey.set(optionItemId.trim()); + } + + private _getOrCreateOptionEmitter(optionGroupId: string): Emitter { + let emitter = this._optionEmitters.get(optionGroupId); + if (!emitter) { + emitter = new Emitter(); + this._optionEmitters.set(optionGroupId, emitter); + this._pickerDisposables.add(emitter); + } + return emitter; + } + + private _clearPickers(): void { + this._pickerDisposables.clear(); + this._pickerWidgets.clear(); + this._optionEmitters.clear(); + if (this._container) { + dom.clearNode(this._container); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts deleted file mode 100644 index 1080d93df7dce..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ /dev/null @@ -1,312 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as dom from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { basename, isEqual } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { INewSession } from './newSession.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; - -const STORAGE_KEY_LAST_FOLDER = 'agentSessions.lastPickedFolder'; -const STORAGE_KEY_RECENT_FOLDERS = 'agentSessions.recentlyPickedFolders'; -const MAX_RECENT_FOLDERS = 10; -const FILTER_THRESHOLD = 10; - -interface IFolderItem { - readonly uri: URI; - readonly label: string; -} - -/** - * A folder picker that uses the action widget dropdown to show a list of - * recently selected and recently opened folders. Remembers the last selected - * folder and recently picked folders in storage. Enables a filter input when - * there are more than 10 items. - */ -export class FolderPicker extends Disposable { - - private readonly _onDidSelectFolder = this._register(new Emitter()); - readonly onDidSelectFolder: Event = this._onDidSelectFolder.event; - - private _selectedFolderUri: URI | undefined; - private _recentlyPickedFolders: URI[] = []; - private _cachedRecentFolders: { uri: URI; label?: string }[] = []; - private _newSession: INewSession | undefined; - - private _triggerElement: HTMLElement | undefined; - private readonly _renderDisposables = this._register(new DisposableStore()); - - get selectedFolderUri(): URI | undefined { - return this._selectedFolderUri; - } - - /** - * Sets the pending session that this picker writes to. - * When the user selects a folder, it calls `setRepoUri` on the session. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - - constructor( - @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, - @IStorageService private readonly storageService: IStorageService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IWorkspacesService private readonly workspacesService: IWorkspacesService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - ) { - super(); - - // Restore last picked folder - const lastFolder = this.storageService.get(STORAGE_KEY_LAST_FOLDER, StorageScope.PROFILE); - if (lastFolder) { - try { this._selectedFolderUri = URI.parse(lastFolder); } catch { /* ignore */ } - } - - // Restore recently picked folders - try { - const stored = this.storageService.get(STORAGE_KEY_RECENT_FOLDERS, StorageScope.PROFILE); - if (stored) { - this._recentlyPickedFolders = JSON.parse(stored).map((s: string) => URI.parse(s)); - } - } catch { /* ignore */ } - - // Pre-fetch recently opened folders, filtering out copilot worktrees - this.workspacesService.getRecentlyOpened().then(recent => { - this._cachedRecentFolders = recent.workspaces - .filter(isRecentFolder) - .filter(r => !this._isCopilotWorktree(r.folderUri)) - .slice(0, MAX_RECENT_FOLDERS) - .map(r => ({ uri: r.folderUri, label: r.label })); - }).catch(() => { /* ignore */ }); - } - - /** - * Renders the folder picker trigger button into the given container. - * Returns the container element. - */ - render(container: HTMLElement): HTMLElement { - this._renderDisposables.clear(); - - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); - this._renderDisposables.add({ dispose: () => slot.remove() }); - - const trigger = dom.append(slot, dom.$('a.action-label')); - trigger.tabIndex = 0; - trigger.role = 'button'; - this._triggerElement = trigger; - - this._updateTriggerLabel(trigger); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { - dom.EventHelper.stop(e, true); - this.showPicker(); - })); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { - if (e.key === 'Enter' || e.key === ' ') { - dom.EventHelper.stop(e, true); - this.showPicker(); - } - })); - - return slot; - } - - /** - * Shows the folder picker dropdown anchored to the trigger element. - */ - showPicker(): void { - if (!this._triggerElement || this.actionWidgetService.isVisible) { - return; - } - - const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; - const items = this._buildItems(currentFolderUri); - const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; - - const triggerElement = this._triggerElement; - const delegate: IActionListDelegate = { - onSelect: (item) => { - this.actionWidgetService.hide(); - if (item.uri.scheme === 'command' && item.uri.path === 'browse') { - this._browseForFolder(); - } else { - this._selectFolder(item.uri); - } - }, - onHide: () => { triggerElement.focus(); }, - }; - - this.actionWidgetService.show( - 'folderPicker', - false, - items, - delegate, - this._triggerElement, - undefined, - [], - { - getAriaLabel: (item) => item.label ?? '', - getWidgetAriaLabel: () => localize('folderPicker.ariaLabel', "Folder Picker"), - }, - showFilter ? { showFilter: true, filterPlaceholder: localize('folderPicker.filter', "Filter folders...") } : undefined, - ); - } - - /** - * Programmatically set the selected folder. - */ - setSelectedFolder(folderUri: URI): void { - this._selectFolder(folderUri); - } - - /** - * Clears the selected folder. - */ - clearSelection(): void { - this._selectedFolderUri = undefined; - this._updateTriggerLabel(this._triggerElement); - } - - private _selectFolder(folderUri: URI): void { - this._selectedFolderUri = folderUri; - this._addToRecentlyPickedFolders(folderUri); - this.storageService.store(STORAGE_KEY_LAST_FOLDER, folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE); - this._updateTriggerLabel(this._triggerElement); - this._newSession?.setRepoUri(folderUri); - this._onDidSelectFolder.fire(folderUri); - } - - private async _browseForFolder(): Promise { - try { - const selected = await this.fileDialogService.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: localize('selectFolder', "Select Folder"), - }); - if (selected?.[0]) { - this._selectFolder(selected[0]); - } - } catch { - // dialog was cancelled or failed — nothing to do - } - } - - private _addToRecentlyPickedFolders(folderUri: URI): void { - this._recentlyPickedFolders = [folderUri, ...this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri))].slice(0, MAX_RECENT_FOLDERS); - this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); - } - - private _buildItems(currentFolderUri: URI | undefined): IActionListItem[] { - const seenUris = new Set(); - if (currentFolderUri) { - seenUris.add(currentFolderUri.toString()); - } - - const items: IActionListItem[] = []; - - // Currently selected folder (shown first, checked) - if (currentFolderUri) { - items.push({ - kind: ActionListItemKind.Action, - label: basename(currentFolderUri), - group: { title: '', icon: Codicon.check }, - item: { uri: currentFolderUri, label: basename(currentFolderUri) }, - }); - } - - // Combine recently picked folders and recently opened folders - const allFolders: { uri: URI; label?: string }[] = [ - ...this._recentlyPickedFolders.map(uri => ({ uri })), - ...this._cachedRecentFolders, - ]; - for (const folder of allFolders) { - const key = folder.uri.toString(); - if (seenUris.has(key)) { - continue; - } - seenUris.add(key); - const label = folder.label || basename(folder.uri); - items.push({ - kind: ActionListItemKind.Action, - label, - group: { title: '', icon: Codicon.blank }, - item: { uri: folder.uri, label }, - toolbarActions: [toAction({ - id: 'folderPicker.remove', - label: localize('folderPicker.remove', "Remove"), - class: ThemeIcon.asClassName(Codicon.close), - run: () => this._removeFolder(folder.uri), - })], - }); - } - - // Separator + Browse... - if (items.length > 0) { - items.push({ - kind: ActionListItemKind.Separator, - label: '', - }); - } - items.push({ - kind: ActionListItemKind.Action, - label: localize('browseFolder', "Browse..."), - group: { title: '', icon: Codicon.folderOpened }, - item: { uri: URI.from({ scheme: 'command', path: 'browse' }), label: localize('browseFolder', "Browse...") }, - }); - - return items; - } - - private _removeFolder(folderUri: URI): void { - // Remove from recently picked folders - this._recentlyPickedFolders = this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri)); - this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); - - // Remove from cached recent folders - this._cachedRecentFolders = this._cachedRecentFolders.filter(f => !isEqual(f.uri, folderUri)); - - // Remove from globally recently opened - this.workspacesService.removeRecentlyOpened([folderUri]); - - // Re-show the picker with updated items - this.actionWidgetService.hide(); - this.showPicker(); - } - - private _isCopilotWorktree(uri: URI): boolean { - const name = basename(uri); - return name.startsWith('copilot-worktree-'); - } - - private _updateTriggerLabel(trigger: HTMLElement | undefined): void { - if (!trigger) { - return; - } - - dom.clearNode(trigger); - const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; - const label = folderUri ? basename(folderUri) : localize('pickFolder', "Pick Folder"); - - dom.append(trigger, renderIcon(Codicon.folder)); - const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); - labelSpan.textContent = label; - dom.append(trigger, renderIcon(Codicon.chevronDown)); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 89375629adb1a..0c6223fb1e5e7 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,7 +12,7 @@ height: 100%; box-sizing: border-box; overflow: hidden; - padding-bottom: 10%; + padding: 0 16px 48px 16px; container-type: size; } @@ -35,7 +35,7 @@ width: 100%; max-width: 200px; aspect-ratio: 1/1; - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-dark.svg'); + background-image: url('./letterpress-sessions-dark.svg'); background-size: contain; background-position: center; background-repeat: no-repeat; @@ -43,16 +43,9 @@ margin-bottom: 20px; } -.vs .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-light.svg'); -} - +.vs .chat-full-welcome-letterpress, .hc-light .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcLight.svg'); -} - -.hc-black .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcDark.svg'); + background-image: url('./letterpress-sessions-light.svg'); } @container (max-height: 350px) { @@ -112,10 +105,13 @@ width: 100%; max-width: 800px; margin-top: 8px; + padding-left: 7px; + padding-right: 7px; box-sizing: border-box; display: none; flex-direction: row; align-items: center; + gap: 4px; min-height: 28px; } @@ -171,6 +167,7 @@ flex-direction: row; flex-wrap: nowrap; align-items: center; + justify-content: center; width: 100%; box-sizing: border-box; padding: 0; @@ -180,96 +177,34 @@ display: none; } -/* Left half: target switcher, right-justified */ -.sessions-chat-pickers-left-half { - flex: 1; - display: flex; - justify-content: flex-end; - align-items: center; - min-width: 0; +/* Prominent project picker button */ +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { + height: auto; + padding: 8px 20px; + font-size: 15px; + border: 1px solid var(--vscode-button-border); + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border-radius: 6px; } -/* Right half: pickers, left-justified */ -.sessions-chat-pickers-right-half { - flex: 1; - display: flex; - justify-content: flex-start; - align-items: center; - min-width: 0; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label:hover { + background-color: var(--vscode-button-secondaryHoverBackground); } -/* Separator between switcher and folder picker */ -.sessions-chat-pickers-left-separator { - width: 1px; - height: 22px; - background-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); - margin: 0 12px; - flex-shrink: 0; - display: none; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .codicon { + font-size: 18px; + margin-right: 2px; } -/* Target switcher radio buttons - bigger and fancier */ -.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button { - font-size: 13px; - line-height: 1.4em; - padding: 4px 14px; -} - -.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:first-child { - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; +.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .codicon-chevron-down { + margin-right: 0; + position: relative; + top: 1px; } -.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:last-child { - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; -} - -.sessions-chat-dropdown-wrapper .monaco-custom-radio > .monaco-button:focus { - outline: none; -} - -/* Folder label next to the picker */ -.sessions-chat-folder-label { - font-size: 13px; - color: var(--vscode-descriptionForeground); - white-space: nowrap; - margin-right: 6px; -} - -/* Target dropdown button */ -.sessions-chat-dropdown-button { - display: flex; - align-items: center; - height: 16px; - padding: 3px 3px 3px 6px; - cursor: pointer; - font-size: 13px; - color: var(--vscode-descriptionForeground); - background-color: transparent; - border: none; - white-space: nowrap; - border-radius: 4px; -} - -.sessions-chat-dropdown-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.sessions-chat-dropdown-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.sessions-chat-dropdown-button .codicon { - font-size: 14px; - flex-shrink: 0; -} - -.sessions-chat-dropdown-button .codicon.codicon-chevron-down { - font-size: 12px; - margin-left: 2px; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { + font-size: 15px; } .sessions-chat-dropdown-label { @@ -291,7 +226,7 @@ padding: 3px 3px 3px 6px; background-color: transparent; border: none; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); font-size: 13px; cursor: pointer; white-space: nowrap; @@ -313,7 +248,13 @@ .sessions-chat-picker-slot.disabled .action-label:hover { background-color: transparent; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); +} + +.chat-input-picker-item .action-label.disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; } .sessions-chat-picker-slot.loading .action-label { @@ -350,7 +291,7 @@ .sessions-chat-picker-slot .action-label .codicon-chevron-down { font-size: 12px; - margin-left: 2px; + margin-left: 6px; } .sessions-chat-picker-slot .action-label .chat-session-option-label { @@ -361,3 +302,40 @@ .sessions-chat-picker-slot .action-label span + .chat-session-option-label { margin-left: 2px; } + +.sessions-chat-picker-slot .action-label.warning { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 0.75; +} + +.sessions-chat-picker-slot .action-label.warning .codicon { + color: var(--vscode-problemsWarningIcon-foreground) !important; +} + +.sessions-chat-picker-slot .action-label.warning:hover { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 1; +} + +.sessions-chat-picker-slot .action-label.info { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 0.75; +} + +.sessions-chat-picker-slot .action-label.info .codicon { + color: var(--vscode-problemsInfoIcon-foreground) !important; +} + +.sessions-chat-picker-slot .action-label.info:hover { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 1; +} + +/* Sync indicator: a slim non-interactive-looking separator before the button */ +.sessions-chat-sync-indicator { + margin-left: 4px; +} + +.sessions-chat-sync-indicator .action-label .sessions-chat-dropdown-label { + margin-left: 3px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 4fca1a7a5018a..70276f22fcbea 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -8,6 +8,7 @@ flex-direction: column; height: 100%; width: 100%; + position: relative; } /* Welcome container fills available space and centers content */ @@ -38,11 +39,9 @@ } /* Editor */ +/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ .sessions-chat-editor { padding: 0 6px 6px 6px; - height: 50px; - min-height: 36px; - max-height: 200px; flex-shrink: 1; } @@ -66,30 +65,59 @@ flex: 1; } +.sessions-chat-toolbar-pickers { + display: flex; + align-items: center; + gap: 4px; +} + /* Model picker - uses workbench ModelPickerActionItem */ -.sessions-chat-model-picker { +/* Session config toolbar (mode, model pickers via MenuWorkbenchToolBar) */ +.sessions-chat-config-toolbar { display: flex; align-items: center; + min-width: 0; + overflow: hidden; } -.sessions-chat-model-picker .action-label { +.sessions-chat-config-toolbar .monaco-toolbar { + height: auto; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item { + display: flex; + align-items: center; +} + +.sessions-chat-config-toolbar .action-label { display: flex; align-items: center; - gap: 4px; height: 16px; - padding: 3px 6px; + padding: 3px 3px 3px 6px; + background-color: transparent; + border: none; border-radius: 4px; - font-size: 12px; + font-size: 13px; cursor: pointer; color: var(--vscode-icon-foreground); + white-space: nowrap; + min-width: 0; + overflow: hidden; } -.sessions-chat-model-picker .action-label:hover { +.sessions-chat-config-toolbar .action-label:hover { background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-config-toolbar .action-label .codicon { + font-size: 14px; + flex-shrink: 0; } -.sessions-chat-model-picker .action-label .codicon { +.sessions-chat-config-toolbar .action-label .codicon-chevron-down { font-size: 12px; + margin-left: 6px; } /* Send button - wraps a Button widget */ @@ -110,6 +138,11 @@ color: var(--vscode-icon-foreground); background: transparent !important; border: none !important; + cursor: pointer; +} + +.sessions-chat-send-button .monaco-button.disabled { + cursor: default; } .sessions-chat-send-button .monaco-button:not(.disabled):hover { @@ -222,6 +255,29 @@ padding: 0 3px; } +.sessions-chat-attachment-pill .monaco-icon-label { + gap: 4px; +} + +.sessions-chat-attachment-pill .monaco-icon-label::before { + height: auto; + padding: 0 0 0 2px; + line-height: 100% !important; + align-self: center; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container { + display: flex; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container .monaco-highlighted-label { + display: inline-flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .sessions-chat-attachment-remove { display: flex; align-items: center; @@ -246,17 +302,37 @@ } /* Drag and drop */ -.sessions-chat-drop-overlay { - display: none; +.sessions-chat-dnd-overlay { position: absolute; top: 0; left: 0; - right: 0; - bottom: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + display: none; z-index: 10; + background-color: var(--vscode-sideBar-dropBackground, var(--vscode-list-dropBackground)); +} + +.sessions-chat-dnd-overlay.visible { + display: flex; + align-items: center; + justify-content: center; } -.sessions-chat-input-area.sessions-chat-drop-active { - border-color: var(--vscode-focusBorder); - background-color: var(--vscode-list-dropBackground); +.sessions-chat-dnd-overlay .attach-context-overlay-text { + padding: 0.6em; + margin: 0.2em; + line-height: 12px; + height: 12px; + display: flex; + align-items: center; + text-align: center; + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +.sessions-chat-dnd-overlay .attach-context-overlay-text .codicon { + height: 12px; + font-size: 12px; + margin-right: 3px; } diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg deleted file mode 100644 index 81991ee80fa80..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg deleted file mode 100644 index 55db4d45e46fb..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg deleted file mode 100644 index e26c10e038aa0..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg deleted file mode 100644 index e26c10e038aa0..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg new file mode 100644 index 0000000000000..623629695fc17 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg new file mode 100644 index 0000000000000..29dfd5459d13c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css new file mode 100644 index 0000000000000..0837bc7b8c0e8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.run-script-action-widget { + display: flex; + flex-direction: column; + gap: 12px; + background-color: var(--vscode-quickInput-background); + padding: 8px 8px 12px; +} + +.run-script-action-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.run-script-action-label { + font-size: 12px; + font-weight: 600; +} + +.run-script-action-input .monaco-inputbox { + width: 100%; +} + +.run-script-action-option-row { + display: flex; + align-items: center; + min-height: 22px; + gap: 8px; +} + +.run-script-action-option-text { + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.run-script-action-section .monaco-custom-radio { + width: fit-content; + max-width: 100%; +} + +.run-script-action-hint { + font-size: 12px; + opacity: 0.8; +} + +.run-script-action-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} diff --git a/extensions/github-authentication/extension.webpack.config.js b/src/vs/sessions/contrib/chat/browser/modePicker.ts similarity index 65% rename from extensions/github-authentication/extension.webpack.config.js rename to src/vs/sessions/contrib/chat/browser/modePicker.ts index 166c1d8b1e340..a4a092d83492f 100644 --- a/extensions/github-authentication/extension.webpack.config.js +++ b/src/vs/sessions/contrib/chat/browser/modePicker.ts @@ -2,12 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts', - }, -}); diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts similarity index 64% rename from src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts rename to src/vs/sessions/contrib/chat/browser/modelPicker.ts index 99549752c8ab5..a4a092d83492f 100644 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -2,10 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { HoldToSpeak } from './inlineChatActions.js'; - -// start and hold for voice - -registerAction2(HoldToSpeak); diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 7219eaeafd80f..0c264d25f9a24 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -4,32 +4,42 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; +import { DragAndDropObserver } from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { renderIcon, renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js'; +import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js'; import { basename } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isLocation } from '../../../../editor/common/languages.js'; import { resizeImage } from '../../../../workbench/contrib/chat/browser/chatImageUtils.js'; import { imageToHash, isImage } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js'; -import { getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { CodeDataTransfers, containsDragType, extractEditorsDropData, getPathForFile } from '../../../../platform/dnd/browser/dnd.js'; +import { DataTransfers } from '../../../../base/browser/dnd.js'; import { getExcludes, ISearchConfiguration, ISearchService, QueryType } from '../../../../workbench/services/search/common/search.js'; /** @@ -54,6 +64,15 @@ export class NewChatContextAttachments extends Disposable { return this._attachedContext; } + setAttachments(entries: readonly IChatRequestVariableEntry[]): void { + this._attachedContext.length = 0; + this._attachedContext.push(...entries); + this._updateRendering(); + this._onDidChangeContext.fire(); + } + + private readonly _resourceLabels: ResourceLabels; + constructor( @IQuickInputService private readonly quickInputService: IQuickInputService, @ITextModelService private readonly textModelService: ITextModelService, @@ -64,8 +83,13 @@ export class NewChatContextAttachments extends Disposable { @ISearchService private readonly searchService: ISearchService, @IConfigurationService private readonly configurationService: IConfigurationService, @IOpenerService private readonly openerService: IOpenerService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, + @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, ) { super(); + this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); } // --- Rendering --- @@ -81,6 +105,7 @@ export class NewChatContextAttachments extends Disposable { } this._renderDisposables.clear(); + this._resourceLabels.clear(); dom.clearNode(this._container); if (this._attachedContext.length === 0) { @@ -89,18 +114,42 @@ export class NewChatContextAttachments extends Disposable { } this._container.style.display = ''; + this._container.classList.add('show-file-icons'); for (const entry of this._attachedContext) { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); pill.tabIndex = 0; pill.role = 'button'; - const icon = entry.kind === 'image' ? Codicon.fileMedia : Codicon.file; - dom.append(pill, renderIcon(icon)); - dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); - - // Click to open the resource const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; - if (resource) { + if (entry.kind === 'image') { + dom.append(pill, renderIcon(Codicon.fileMedia)); + dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); + } else { + const label = this._resourceLabels.create(pill, { supportIcons: true }); + this._renderDisposables.add(label); + if (resource) { + label.setFile(resource, { + fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE, + hidePath: true, + }); + } else { + label.setLabel(entry.name); + } + } + + // Click to open the resource or image + const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; + if (imageData) { + pill.style.cursor = 'pointer'; + this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + if (this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); + await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); + } else if (resource) { + await this.openerService.open(resource, { fromUserGesture: true }); + } + })); + } else if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { await this.openerService.open(resource, { fromUserGesture: true }); @@ -120,68 +169,85 @@ export class NewChatContextAttachments extends Disposable { // --- Drag and drop --- - registerDropTarget(element: HTMLElement): void { - // Use a transparent overlay during drag to capture events over the Monaco editor - const overlay = dom.append(element, dom.$('.sessions-chat-drop-overlay')); + registerDropTarget(dndContainer: HTMLElement): void { + const overlay = dom.append(dndContainer, dom.$('.sessions-chat-dnd-overlay')); + let overlayText: HTMLElement | undefined; - // Use capture phase to intercept drag events before Monaco editor handles them - this._register(dom.addDisposableListener(element, dom.EventType.DRAG_ENTER, (e: DragEvent) => { - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - overlay.style.display = 'block'; - element.classList.add('sessions-chat-drop-active'); - } - }, true)); + const isDropSupported = (e: DragEvent): boolean => { + return containsDragType(e, DataTransfers.FILES, CodeDataTransfers.EDITORS, CodeDataTransfers.FILES, DataTransfers.RESOURCES, DataTransfers.INTERNAL_URI_LIST); + }; - this._register(dom.addDisposableListener(element, dom.EventType.DRAG_OVER, (e: DragEvent) => { - if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - if (overlay.style.display !== 'block') { - overlay.style.display = 'block'; - element.classList.add('sessions-chat-drop-active'); - } + const showOverlay = () => { + overlay.classList.add('visible'); + if (!overlayText) { + const label = localize('attachAsContext', "Attach as Context"); + const iconAndTextElements = renderLabelWithIcons(`$(${Codicon.attach.id}) ${label}`); + const htmlElements = iconAndTextElements.map(element => { + if (typeof element === 'string') { + return dom.$('span.overlay-text', undefined, element); + } + return element; + }); + overlayText = dom.$('span.attach-context-overlay-text', undefined, ...htmlElements); + overlay.appendChild(overlayText); } - }, true)); - - this._register(dom.addDisposableListener(overlay, dom.EventType.DRAG_OVER, (e) => { - e.preventDefault(); - e.dataTransfer!.dropEffect = 'copy'; - })); + }; - this._register(dom.addDisposableListener(overlay, dom.EventType.DRAG_LEAVE, (e) => { - if (e.relatedTarget && element.contains(e.relatedTarget as Node)) { - return; - } - overlay.style.display = 'none'; - element.classList.remove('sessions-chat-drop-active'); - })); + const hideOverlay = () => { + overlay.classList.remove('visible'); + overlayText?.remove(); + overlayText = undefined; + }; - this._register(dom.addDisposableListener(overlay, dom.EventType.DROP, async (e) => { - e.preventDefault(); - e.stopPropagation(); - overlay.style.display = 'none'; - element.classList.remove('sessions-chat-drop-active'); - - // Try items first (for URI-based drops from VS Code tree views) - const items = e.dataTransfer?.items; - if (items) { - for (const item of Array.from(items)) { - if (item.kind === 'file') { - const file = item.getAsFile(); - if (!file) { - continue; + this._register(new DragAndDropObserver(dndContainer, { + onDragOver: (e) => { + if (isDropSupported(e)) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + showOverlay(); + } + }, + onDragLeave: () => { + hideOverlay(); + }, + onDrop: async (e) => { + e.preventDefault(); + e.stopPropagation(); + hideOverlay(); + + // Extract editor data from VS Code internal drags (e.g., explorer view) + const editorDropData = extractEditorsDropData(e); + if (editorDropData.length > 0) { + for (const editor of editorDropData) { + if (editor.resource) { + await this._attachFileUri(editor.resource, basename(editor.resource)); } - const filePath = getPathForFile(file); - if (!filePath) { - continue; + } + return; + } + + // Fallback: try native file items + const items = e.dataTransfer?.items; + if (items) { + for (const item of Array.from(items)) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (!file) { + continue; + } + const filePath = getPathForFile(file); + if (!filePath) { + continue; + } + const uri = URI.file(filePath); + await this._attachFileUri(uri, file.name); } - const uri = URI.file(filePath); - await this._attachFileUri(uri, file.name); } } - } + }, })); } @@ -364,7 +430,7 @@ export class NewChatContextAttachments extends Disposable { return searchResult.results.map(result => ({ label: basename(result.resource), description: this.labelService.getUriLabel(result.resource, { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.file), + iconClasses: getIconClasses(this.modelService, this.languageService, result.resource, FileKind.FILE), id: result.resource.toString(), } satisfies IQuickPickItem)); } catch { @@ -408,7 +474,7 @@ export class NewChatContextAttachments extends Disposable { picks.push({ label: child.name, description: this.labelService.getUriLabel(child.resource, { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.file), + iconClasses: getIconClasses(this.modelService, this.languageService, child.resource, FileKind.FILE), id: child.resource.toString(), }); } @@ -439,6 +505,23 @@ export class NewChatContextAttachments extends Disposable { } private async _attachFileUri(uri: URI, name: string): Promise { + let stat; + try { + stat = await this.fileService.stat(uri); + } catch { + return; + } + + if (stat.isDirectory) { + this._addAttachments({ + kind: 'directory', + id: uri.toString(), + value: uri, + name, + }); + return; + } + if (/\.(png|jpg|jpeg|bmp|gif|tiff)$/i.test(uri.path)) { const readFile = await this.fileService.readFile(uri); const resizedImage = await resizeImage(readFile.value.buffer); diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts new file mode 100644 index 0000000000000..121306272c4b0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -0,0 +1,266 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CopilotCLISession } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import Severity from '../../../../base/common/severity.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; + +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); + +interface IPermissionItem { + readonly level: ChatPermissionLevel; + readonly label: string; + readonly icon: ThemeIcon; + readonly checked: boolean; +} + +/** + * A permission picker for the new-session welcome view. + * Shows Default Approvals, Bypass Approvals, and Autopilot options. + */ +export class NewChatPermissionPicker extends Disposable { + + private readonly _onDidChangeLevel = this._register(new Emitter()); + readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; + + private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + get permissionLevel(): ChatPermissionLevel { + return this._currentLevel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + // Write permission level to the active session data when it changes + this._register(this.onDidChangeLevel(level => { + const session = this.sessionsManagementService.activeSession.get(); + if (!(session instanceof CopilotCLISession)) { + throw new Error('NewChatPermissionPicker requires a CopilotCLISession'); + } + session.setPermissionLevel(level); + })); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(trigger); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + const isAutopilotEnabled = this.configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.shield }, + item: { + level: ChatPermissionLevel.Default, + label: localize('permissions.default', "Default Approvals"), + icon: Codicon.shield, + checked: this._currentLevel === ChatPermissionLevel.Default, + }, + label: localize('permissions.default', "Default Approvals"), + description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + disabled: false, + }, + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.warning }, + item: { + level: ChatPermissionLevel.AutoApprove, + label: localize('permissions.autoApprove', "Bypass Approvals"), + icon: Codicon.warning, + checked: this._currentLevel === ChatPermissionLevel.AutoApprove, + }, + label: localize('permissions.autoApprove', "Bypass Approvals"), + description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + disabled: policyRestricted, + }, + ]; + + if (isAutopilotEnabled) { + items.push({ + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.rocket }, + item: { + level: ChatPermissionLevel.Autopilot, + label: localize('permissions.autopilot', "Autopilot (Preview)"), + icon: Codicon.rocket, + checked: this._currentLevel === ChatPermissionLevel.Autopilot, + }, + label: localize('permissions.autopilot', "Autopilot (Preview)"), + description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + disabled: policyRestricted, + }); + } + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: async (item) => { + this.actionWidgetService.hide(); + await this._selectLevel(item.level); + }, + onHide: () => { triggerElement.focus(); }, + }; + + const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; + this.actionWidgetService.show( + 'permissionPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"), + }, + listOptions, + ); + } + + private async _selectLevel(level: ChatPermissionLevel): Promise { + if (level === ChatPermissionLevel.AutoApprove && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"), + buttons: [ + { + label: localize('permissions.autoApprove.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autoApprove.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.AutoApprove); + } + + if (level === ChatPermissionLevel.Autopilot && !shownWarnings.has(ChatPermissionLevel.Autopilot)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autopilot.warning.title', "Enable Autopilot?"), + buttons: [ + { + label: localize('permissions.autopilot.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autopilot.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.rocket, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.Autopilot); + } + + this._currentLevel = level; + this._updateTriggerLabel(this._triggerElement); + this._onDidChangeLevel.fire(level); + } + + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { + if (!trigger) { + return; + } + + dom.clearNode(trigger); + let icon: ThemeIcon; + let label: string; + switch (this._currentLevel) { + case ChatPermissionLevel.Autopilot: + icon = Codicon.rocket; + label = localize('permissions.autopilot.label', "Autopilot (Preview)"); + break; + case ChatPermissionLevel.AutoApprove: + icon = Codicon.warning; + label = localize('permissions.autoApprove.label', "Bypass Approvals"); + break; + default: + icon = Codicon.shield; + label = localize('permissions.default.label', "Default Approvals"); + break; + } + + dom.append(trigger, renderIcon(icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(trigger, renderIcon(Codicon.chevronDown)); + + trigger.classList.toggle('warning', this._currentLevel === ChatPermissionLevel.Autopilot); + trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7fbfe0a318729..923f3fe7d42a0 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -7,21 +7,21 @@ import './media/chatWidget.css'; import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { toAction } from '../../../../base/common/actions.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../base/common/observable.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; - import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -34,44 +34,39 @@ import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hover import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; -import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; -import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; -import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; -import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js'; -import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; -import { isString } from '../../../../base/common/types.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; -import { FolderPicker } from './folderPicker.js'; -import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; -import { BranchPicker } from './branchPicker.js'; -import { INewSession } from './newSession.js'; -import { getErrorMessage } from '../../../../base/common/errors.js'; - -const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; +import { SessionTypePicker } from './sessionTypePicker.js'; +import { WorkspacePicker, IWorkspaceSelection } from './sessionWorkspacePicker.js'; +import { Menus } from '../../../browser/menus.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { SlashCommandHandler } from './slashCommands.js'; +import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; + + +const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; +const MIN_EDITOR_HEIGHT = 50; +const MAX_EDITOR_HEIGHT = 200; + +interface IDraftState { + inputText: string; + attachments: readonly IChatRequestVariableEntry[]; +} // #region --- Chat Welcome Widget --- -/** - * Options for creating a `NewChatWidget`. - */ -interface INewChatWidgetOptions { - readonly allowedTargets: AgentSessionProviders[]; - readonly defaultTarget: AgentSessionProviders; - readonly sessionPosition?: ChatSessionPosition; -} - /** * A self-contained new-session chat widget with a welcome view (mascot, target * buttons, option pickers), an input editor, model picker, and send button. @@ -79,103 +74,84 @@ interface INewChatWidgetOptions { * This widget is shown only in the empty/welcome state. Once the user sends * a message, a session is created and the workbench ChatViewPane takes over. */ -class NewChatWidget extends Disposable { +class NewChatWidget extends Disposable implements IHistoryNavigationWidget { + + private readonly _workspacePicker: WorkspacePicker; + private readonly _sessionTypePicker: SessionTypePicker; - private readonly _targetPicker: SessionTargetPicker; - private readonly _isolationModePicker: IsolationModePicker; - private readonly _branchPicker: BranchPicker; - private readonly _options: INewChatWidgetOptions; + // IHistoryNavigationWidget + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus = this._onDidFocus.event; + private readonly _onDidBlur = this._register(new Emitter()); + readonly onDidBlur = this._onDidBlur.event; + get element(): HTMLElement { return this._editorContainer; } // Input private _editor!: CodeEditorWidget; - private readonly _currentLanguageModel = observableValue('currentLanguageModel', undefined); - private readonly _modelPickerDisposable = this._register(new MutableDisposable()); - - // Pending session - private readonly _newSession = this._register(new MutableDisposable()); - private readonly _newSessionListener = this._register(new MutableDisposable()); + private _editorContainer!: HTMLElement; // Send button private _sendButton: Button | undefined; private _sending = false; - // Repository loading - private readonly _openRepositoryCts = this._register(new MutableDisposable()); - private _repositoryLoading = false; - private _branchLoading = false; + // Loading state private _loadingSpinner: HTMLElement | undefined; private readonly _loadingDelayDisposable = this._register(new MutableDisposable()); // Welcome part private _pickersContainer: HTMLElement | undefined; - private _extensionPickersLeftContainer: HTMLElement | undefined; - private _extensionPickersRightContainer: HTMLElement | undefined; private _inputSlot: HTMLElement | undefined; - private readonly _folderPicker: FolderPicker; - private _folderPickerContainer: HTMLElement | undefined; - private readonly _pickerWidgets = new Map(); - private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); - private readonly _optionEmitters = new Map>(); - private readonly _selectedOptions = new Map(); - private readonly _optionContextKeys = new Map>(); - private readonly _whenClauseKeys = new Set(); // Attached context private readonly _contextAttachments: NewChatContextAttachments; + // Slash commands + private _slashCommandHandler: SlashCommandHandler | undefined; + + // Input state + private _draftState: IDraftState | undefined = { + inputText: '', + attachments: [], + }; + + // Input history + private readonly _history: ChatHistoryNavigator; + private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement']; + private _historyNavigationForwardsEnablement!: IHistoryNavigationContext['historyNavigationForwardsEnablement']; + constructor( - options: INewChatWidgetOptions, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IModelService private readonly modelService: IModelService, @IConfigurationService private readonly configurationService: IConfigurationService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextMenuService contextMenuService: IContextMenuService, @ILogService private readonly logService: ILogService, @IHoverService private readonly hoverService: IHoverService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, - @IGitService private readonly gitService: IGitService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IStorageService private readonly storageService: IStorageService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); + this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); - this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker)); - this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, options.defaultTarget)); - this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker)); - this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); - this._options = options; - - // When target changes, create new session - this._register(this._targetPicker.onDidChangeTarget((target) => { - this._createNewSession(); - const isLocal = target === AgentSessionProviders.Background; - this._isolationModePicker.setVisible(isLocal); - this._branchPicker.setVisible(isLocal); - this._focusEditor(); - })); + this._workspacePicker = this._register(this.instantiationService.createInstance(WorkspacePicker)); + this._sessionTypePicker = this._register(this.instantiationService.createInstance(SessionTypePicker)); - this._register(this.contextKeyService.onDidChangeContext(e => { - if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { - this._renderExtensionPickers(true); - } - })); - - this._register(this._branchPicker.onDidChangeLoading(loading => { - this._branchLoading = loading; - this._updateInputLoadingState(); - })); - - this._register(this._branchPicker.onDidChange(() => { + // When a workspace is selected, create a new session + this._register(this._workspacePicker.onDidSelectWorkspace(async (workspace) => { + await this._onWorkspaceSelected(workspace); this._focusEditor(); })); - this._register(this._folderPicker.onDidSelectFolder(() => { - this._focusEditor(); + // Update send button and loading state when active session changes or loads + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + const isLoading = session?.loading.read(reader) ?? false; + this._loadingSpinner?.classList.toggle('visible', isLoading); + this._updateSendButtonState(); })); - - this._register(this._isolationModePicker.onDidChange(() => { + this._register(this._contextAttachments.onDidChangeContext(() => { + this._updateDraftState(); this._focusEditor(); })); } @@ -184,6 +160,12 @@ class NewChatWidget extends Disposable { render(container: HTMLElement): void { const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); + + // Overflow widget DOM node at the top level so the suggest widget + // is not clipped by any overflow:hidden ancestor. + const editorOverflowWidgetsDomNode = dom.append(container, dom.$('.sessions-chat-editor-overflow.monaco-editor')); + this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); + const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); // Watermark letterpress @@ -198,7 +180,7 @@ class NewChatWidget extends Disposable { // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); - this._contextAttachments.registerDropTarget(inputArea); + this._contextAttachments.registerDropTarget(wrapper); this._contextAttachments.registerPasteHandler(inputArea); // Attachments row (pills only) inside input area, above editor @@ -206,30 +188,43 @@ class NewChatWidget extends Disposable { const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); this._contextAttachments.renderAttachedContext(attachedContextContainer); - this._createEditor(inputArea); + this._createEditor(inputArea, editorOverflowWidgetsDomNode); this._createBottomToolbar(inputArea); this._inputSlot.appendChild(inputArea); - // Isolation mode and branch pickers (below the input, shown when Local target is selected) - const isolationContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); - this._isolationModePicker.render(isolationContainer); - dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-spacer')); - const branchContainer = dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-right')); - this._branchPicker.render(branchContainer); - - // Set initial visibility based on default target - const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background; - this._isolationModePicker.setVisible(isLocal); - this._branchPicker.setVisible(isLocal); + // Below-input row: session type picker, permission control, spacer, repository config (right) + const belowInputRow = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); + this._sessionTypePicker.render(belowInputRow); + const controlContainer = dom.append(belowInputRow, dom.$('.sessions-chat-control-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, controlContainer, Menus.NewSessionControl, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); + dom.append(belowInputRow, dom.$('.sessions-chat-local-mode-spacer')); + const repoConfigContainer = dom.append(belowInputRow, dom.$('.sessions-chat-local-mode-right')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, repoConfigContainer, Menus.NewSessionRepositoryConfig, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); - // Render target buttons & extension pickers + // Render project picker & extension pickers this._renderOptionGroupPickers(); - // Initialize model picker - this._initDefaultModel(); + // Restore draft input state from storage + this._restoreState(); - // Create initial session - this._createNewSession(); + // Create initial session — wait for providers if none registered yet + const restoredProject = this._workspacePicker.selectedProject; + if (restoredProject) { + if (this.sessionsProvidersService.getProviders().length > 0) { + this._createNewSession(restoredProject); + } else { + // Providers not yet registered (startup race) — wait for first registration + const sub = this.sessionsProvidersService.onDidChangeProviders(() => { + sub.dispose(); + this._createNewSession(restoredProject); + }); + this._register(sub); + } + } // Reveal welcomeElement.classList.add('revealed'); @@ -240,101 +235,17 @@ class NewChatWidget extends Disposable { }, { once: true })); } - private async _createNewSession(): Promise { - const target = this._targetPicker.selectedTarget; - const defaultRepoUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; - const resource = getResourceForNewChatSession({ - type: target, - position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, - displayName: '', - }); - - try { - const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, defaultRepoUri); - this._setNewSession(session); - } catch (e) { - this.logService.error('Failed to create new session:', e); - } - } - - private _setNewSession(session: INewSession): void { - this._newSession.value = session; - - // Wire pickers to the new session - this._folderPicker.setNewSession(session); - this._isolationModePicker.setNewSession(session); - this._branchPicker.setNewSession(session); - - // Set the current model on the session - const currentModel = this._currentLanguageModel.get(); - if (currentModel) { - session.setModelId(currentModel.identifier); - } - - // Open repository for the session's repoUri - if (session.repoUri) { - this._openRepository(session.repoUri); - } - - // Render extension pickers for the new session - this._renderExtensionPickers(true); - - // Listen for session changes - this._newSessionListener.value = session.onDidChange((changeType) => { - if (changeType === 'repoUri' && session.repoUri) { - this._openRepository(session.repoUri); - } - if (changeType === 'isolationMode') { - this._branchPicker.setVisible(session.isolationMode === 'worktree'); - } - if (changeType === 'options') { - this._syncOptionsFromSession(session.resource); - this._renderExtensionPickers(); - } - if (changeType === 'disabled') { - this._updateSendButtonState(); - } - }); - - this._updateSendButtonState(); - } - - private _openRepository(folderUri: URI): void { - this._openRepositoryCts.value?.cancel(); - const cts = this._openRepositoryCts.value = new CancellationTokenSource(); - - this._repositoryLoading = true; - this._updateInputLoadingState(); - this._branchPicker.setRepository(undefined); - this._isolationModePicker.setRepository(undefined); - - this.gitService.openRepository(folderUri).then(repository => { - if (cts.token.isCancellationRequested) { - return; - } - this._repositoryLoading = false; - this._updateInputLoadingState(); - this._isolationModePicker.setRepository(repository); - this._branchPicker.setRepository(repository); - }).catch(e => { - if (cts.token.isCancellationRequested) { - return; - } - this.logService.warn(`Failed to open repository at ${folderUri.toString()}`, getErrorMessage(e)); - this._repositoryLoading = false; - this._updateInputLoadingState(); - this._isolationModePicker.setRepository(undefined); - this._branchPicker.setRepository(undefined); - }); + private _createNewSession(selection: IWorkspaceSelection): void { + this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace); } private _updateInputLoadingState(): void { - const loading = this._repositoryLoading || this._branchLoading || this._sending; + const loading = this._sending; if (loading) { if (!this._loadingDelayDisposable.value) { const timer = setTimeout(() => { this._loadingDelayDisposable.clear(); - if (this._repositoryLoading || this._branchLoading || this._sending) { + if (this._sending) { this._loadingSpinner?.classList.add('visible'); } }, 500); @@ -348,8 +259,18 @@ class NewChatWidget extends Disposable { // --- Editor --- - private _createEditor(container: HTMLElement): void { - const editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { + const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; + + // Create scoped context key service and register history navigation + // BEFORE creating the editor, so the editor's context key scope is a child + const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(container)); + const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); + this._historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; + this._historyNavigationForwardsEnablement = historyNavigationForwardsEnablement; + + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); const textModel = this._register(this.modelService.createModel('', null, uri, true)); @@ -362,37 +283,91 @@ class NewChatWidget extends Disposable { fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: 13, lineHeight: 20, + cursorWidth: 1, padding: { top: 8, bottom: 2 }, wrappingStrategy: 'advanced', stickyScroll: { enabled: false }, renderWhitespace: 'none', + overflowWidgetsDomNode, + suggest: { + showIcons: false, + showSnippets: false, + showWords: true, + showStatusBar: false, + insertMode: 'insert', + }, }; const widgetOptions: ICodeEditorWidgetOptions = { isSimpleWidget: true, contributions: EditorExtensionsRegistry.getSomeEditorContributions([ ContextMenuController.ID, + SuggestController.ID, + SnippetController2.ID, ]), }; - this._editor = this._register(this.instantiationService.createInstance( + this._editor = this._register(scopedInstantiationService.createInstance( CodeEditorWidget, editorContainer, editorOptions, widgetOptions, )); this._editor.setModel(textModel); + // Ensure suggest widget renders above the input (not clipped by container) + SuggestController.get(this._editor)?.forceRenderingAbove(); + + this._register(this._editor.onDidFocusEditorWidget(() => this._onDidFocus.fire())); + this._register(this._editor.onDidBlurEditorWidget(() => this._onDidBlur.fire())); + this._register(this._editor.onKeyDown(e => { if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { + // Don't send if the suggest widget is visible (let it accept the completion) + if (this._editor.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this._send(); + } + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && e.altKey) { e.preventDefault(); e.stopPropagation(); this._send(); } })); - this._register(this._editor.onDidContentSizeChange(() => { + // Update history navigation enablement based on cursor position + const updateHistoryNavigationEnablement = () => { + const model = this._editor.getModel(); + const position = this._editor.getPosition(); + if (!model || !position) { + return; + } + this._historyNavigationBackwardsEnablement.set(position.lineNumber === 1 && position.column === 1); + this._historyNavigationForwardsEnablement.set(position.lineNumber === model.getLineCount() && position.column === model.getLineMaxColumn(position.lineNumber)); + }; + this._register(this._editor.onDidChangeCursorPosition(() => updateHistoryNavigationEnablement())); + updateHistoryNavigationEnablement(); + + let previousHeight = -1; + this._register(this._editor.onDidContentSizeChange(e => { + if (!e.contentHeightChanged) { + return; + } + const contentHeight = this._editor.getContentHeight(); + const clampedHeight = Math.min(MAX_EDITOR_HEIGHT, Math.max(MIN_EDITOR_HEIGHT, contentHeight)); + if (clampedHeight === previousHeight) { + return; + } + previousHeight = clampedHeight; + this._editorContainer.style.height = `${clampedHeight}px`; this._editor.layout(); })); + // Slash commands + this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor)); + this._register(this._editor.onDidChangeModelContent(() => { + this._updateDraftState(); this._updateSendButtonState(); })); } @@ -403,10 +378,15 @@ class NewChatWidget extends Disposable { private _createAttachButton(container: HTMLElement): void { const attachButton = dom.append(container, dom.$('.sessions-chat-attach-button')); + const attachButtonLabel = localize('addContext', "Add Context..."); attachButton.tabIndex = 0; attachButton.role = 'button'; - attachButton.title = localize('addContext', "Add Context..."); - attachButton.ariaLabel = localize('addContext', "Add Context..."); + attachButton.ariaLabel = attachButtonLabel; + this._register(this.hoverService.setupDelayedHover(attachButton, { + content: attachButtonLabel, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } + })); dom.append(attachButton, renderIcon(Codicon.add)); this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { this._contextAttachments.showPicker(this._getContextFolderUri()); @@ -414,31 +394,10 @@ class NewChatWidget extends Disposable { } /** - * Returns the folder URI for the context picker based on the current target. - * Local targets use the workspace folder; cloud targets construct a github-remote-file:// URI. + * Returns the workspace URI for the context picker based on the current workspace selection. */ private _getContextFolderUri(): URI | undefined { - const target = this._targetPicker.selectedTarget; - - if (target === AgentSessionProviders.Background) { - return this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; - } - - // For cloud targets, look for a repository option in the selected options - for (const [groupId, option] of this._selectedOptions) { - if (isRepoOrFolderGroup({ id: groupId, name: groupId, items: [] })) { - const nwo = option.id; // e.g. "owner/repo" - if (nwo && nwo.includes('/')) { - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${nwo}/HEAD`, - }); - } - } - } - - return undefined; + return this._workspacePicker.selectedProject?.workspace.repositories[0]?.uri; } private _createBottomToolbar(container: HTMLElement): void { @@ -446,8 +405,12 @@ class NewChatWidget extends Disposable { this._createAttachButton(toolbar); - const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); - this._createModelPicker(modelPickerContainer); + // Session config pickers (mode, model) — rendered via MenuWorkbenchToolBar + // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) + const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); @@ -465,68 +428,6 @@ class NewChatWidget extends Disposable { this._updateSendButtonState(); } - // --- Model picker --- - - private _createModelPicker(container: HTMLElement): void { - const delegate: IModelPickerDelegate = { - currentModel: this._currentLanguageModel, - setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { - this._currentLanguageModel.set(model, undefined); - this.storageService.store(STORAGE_KEY_LAST_MODEL, model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); - this._newSession.value?.setModelId(model.identifier); - this._focusEditor(); - }, - getModels: () => this._getAvailableModels(), - canManageModels: () => false, - }; - - const pickerOptions: IChatInputPickerOptions = { - onlyShowIconsForDefaultActions: observableValue('onlyShowIcons', false), - hoverPosition: { hoverPosition: HoverPosition.ABOVE }, - }; - - const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; - - const modelPicker = this.instantiationService.createInstance( - EnhancedModelPickerActionItem, action, delegate, pickerOptions, - ); - this._modelPickerDisposable.value = modelPicker; - modelPicker.render(container); - } - - private _initDefaultModel(): void { - const lastModelId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); - const models = this._getAvailableModels(); - const lastModel = lastModelId ? models.find(m => m.identifier === lastModelId) : undefined; - if (lastModel) { - this._currentLanguageModel.set(lastModel, undefined); - } else if (models.length > 0) { - this._currentLanguageModel.set(models[0], undefined); - } - - this._register(this.languageModelsService.onDidChangeLanguageModels(() => { - if (!this._currentLanguageModel.get()) { - const storedId = this.storageService.get(STORAGE_KEY_LAST_MODEL, StorageScope.PROFILE); - const updated = this._getAvailableModels(); - const stored = storedId ? updated.find(m => m.identifier === storedId) : undefined; - if (stored) { - this._currentLanguageModel.set(stored, undefined); - } else if (updated.length > 0) { - this._currentLanguageModel.set(updated[0], undefined); - } - } - })); - } - - private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { - return this.languageModelsService.getLanguageModelIds() - .map(id => { - const metadata = this.languageModelsService.lookupLanguageModel(id); - return metadata ? { metadata, identifier: id } : undefined; - }) - .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && m.metadata.targetChatSessionType === AgentSessionProviders.Background); - } - // --- Welcome: Target & option pickers (dropdown row below input) --- private _renderOptionGroupPickers(): void { @@ -534,284 +435,247 @@ class NewChatWidget extends Disposable { return; } - this._clearExtensionPickers(); dom.clearNode(this._pickersContainer); const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); - // Left half: target switcher (right-justified within its half) - const leftHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left-half')); - const targetDropdownContainer = dom.append(leftHalf, dom.$('.sessions-chat-dropdown-wrapper')); - this._targetPicker.render(targetDropdownContainer); - - // Right half: separator + pickers (left-justified within its half) - const rightHalf = dom.append(pickersRow, dom.$('.sessions-chat-pickers-right-half')); - this._extensionPickersLeftContainer = dom.append(rightHalf, dom.$('.sessions-chat-pickers-left-separator')); - this._extensionPickersRightContainer = dom.append(rightHalf, dom.$('.sessions-chat-extension-pickers-right')); + // Project picker (unified folder + repo picker) + this._workspacePicker.render(pickersRow); + } - // Folder picker (rendered once, shown/hidden based on target) - this._folderPickerContainer = this._folderPicker.render(rightHalf); - this._folderPickerContainer.style.display = 'none'; + // --- Input History (IHistoryNavigationWidget) --- - this._renderExtensionPickers(); + showPreviousValue(): void { + if (this._history.isAtStart()) { + return; + } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._toHistoryEntry(this._draftState)); + } + this._navigateHistory(true); } - // --- Welcome: Extension option pickers (Cloud target only) --- - - private _renderExtensionPickers(force?: boolean): void { - if (!this._extensionPickersRightContainer) { + showNextValue(): void { + if (this._history.isAtEnd()) { return; } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._toHistoryEntry(this._draftState)); + } + this._navigateHistory(false); + } - const activeSessionType = this._targetPicker.selectedTarget; + private _updateDraftState(): void { + this._draftState = { + inputText: this._editor?.getModel()?.getValue() ?? '', + attachments: [...this._contextAttachments.attachments], + }; + } - // Extension pickers are only shown for Cloud target - if (activeSessionType === AgentSessionProviders.Background) { - this._clearExtensionPickers(); - if (this._folderPickerContainer) { - this._folderPickerContainer.style.display = ''; - } - if (this._extensionPickersLeftContainer) { - this._extensionPickersLeftContainer.style.display = 'block'; + private _toHistoryEntry(draft: IDraftState): IChatModelInputState { + return { + ...draft, + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: undefined, + selections: [], + contrib: {}, + }; + } + + private _navigateHistory(previous: boolean): void { + const entry = previous ? this._history.previous() : this._history.next(); + const inputText = entry?.inputText ?? ''; + if (entry) { + this._editor?.getModel()?.setValue(inputText); + this._contextAttachments.setAttachments(entry.attachments); + } + aria.status(inputText); + if (previous) { + this._editor.setPosition({ lineNumber: 1, column: 1 }); + } else { + const model = this._editor.getModel(); + if (model) { + const lastLine = model.getLineCount(); + this._editor.setPosition({ lineNumber: lastLine, column: model.getLineMaxColumn(lastLine) }); } + } + } + + // --- Send --- + + private _updateSendButtonState(): void { + if (!this._sendButton) { return; } + const hasText = !!this._editor?.getModel()?.getValue().trim(); + const session = this.sessionsManagementService.activeSession.get(); + const hasActiveSession = !!session; + const isLoading = session?.loading.get() ?? false; + this._sendButton.enabled = !this._sending && hasText && hasActiveSession && !isLoading; + } - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - if (!optionGroups || optionGroups.length === 0) { - this._clearExtensionPickers(); + private async _send(): Promise { + let query = this._editor.getModel()?.getValue().trim(); + if (!query || this._sending) { return; } - const visibleGroups: IChatSessionProviderOptionGroup[] = []; - this._whenClauseKeys.clear(); - for (const group of optionGroups) { - if (isModelOptionGroup(group)) { - continue; - } - if (group.when) { - const expr = ContextKeyExpr.deserialize(group.when); - if (expr) { - for (const key of expr.keys()) { - this._whenClauseKeys.add(key); - } - } - } - const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; - const passesWhenClause = this._evaluateOptionGroupVisibility(group); - if (hasItems && passesWhenClause) { - visibleGroups.push(group); - } + // If no workspace is selected, open the picker + if (!this._hasRequiredRepoOrFolderSelection()) { + this._openRepoOrFolderPicker(); + return; } - if (visibleGroups.length === 0) { - this._clearExtensionPickers(); + // Check for slash commands first + if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) { + this._editor.getModel()?.setValue(''); return; } - if (!force && this._pickerWidgets.size === visibleGroups.length) { - const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); - if (allMatch) { - return; - } + // Expand prompt/skill slash commands into a CLI-friendly reference + const expanded = this._slashCommandHandler?.tryExpandPromptSlashCommand(query); + if (expanded) { + query = expanded; } - this._clearExtensionPickers(); + const attachedContext = this._contextAttachments.attachments.length > 0 + ? [...this._contextAttachments.attachments] + : undefined; - if (this._extensionPickersLeftContainer) { - this._extensionPickersLeftContainer.style.display = 'block'; + if (this._draftState) { + this._history.append(this._toHistoryEntry(this._draftState)); } + this._clearDraftState(); - for (const optionGroup of visibleGroups) { - const initialItem = this._getDefaultOptionForGroup(optionGroup); - const initialState = { group: optionGroup, item: initialItem }; + this._sending = true; + this._editor.updateOptions({ readOnly: true }); + this._updateSendButtonState(); + this._updateInputLoadingState(); - if (initialItem) { - this._updateOptionContextKey(optionGroup.id, initialItem.id); + try { + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return; } + await this.sessionsManagementService.sendRequest(session, { query, attachedContext }); + this._contextAttachments.clear(); + this._editor.getModel()?.setValue(''); + } catch (e) { + this.logService.error('Failed to send request:', e); + } - const emitter = this._getOrCreateOptionEmitter(optionGroup.id); - const itemDelegate: IChatSessionPickerDelegate = { - getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup), - onDidChangeOption: emitter.event, - setOption: (option: IChatSessionProviderOptionItem) => { - this._selectedOptions.set(optionGroup.id, option); - this._updateOptionContextKey(optionGroup.id, option.id); - emitter.fire(option); - - this._newSession.value?.setOption(optionGroup.id, option); - - this._renderExtensionPickers(true); - this._focusEditor(); - }, - getOptionGroup: () => { - const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - return groups?.find((g: { id: string }) => g.id === optionGroup.id); - }, - getSessionResource: () => this._newSession.value?.resource, - }; - - const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); - const widget = this.instantiationService.createInstance( - optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, - action, initialState, itemDelegate - ); + this._sending = false; + this._editor.updateOptions({ readOnly: false }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + } - this._pickerWidgetDisposables.add(widget); - this._pickerWidgets.set(optionGroup.id, widget); + /** + * Checks whether the required folder/repo selection exists for the given session type. + * For Local/Background targets, checks the folder picker. + * For other targets, checks extension-contributed repo/folder option groups. + */ + private _hasRequiredRepoOrFolderSelection(): boolean { + return !!this._workspacePicker.selectedProject; + } - const slot = dom.append(this._extensionPickersRightContainer!, dom.$('.sessions-chat-picker-slot')); - widget.render(slot); - } + private _openRepoOrFolderPicker(): void { + this._workspacePicker.showPicker(); } - private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { - if (!optionGroup.when) { - return true; + private async _requestFolderTrust(folderUri: URI): Promise { + const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ + uri: folderUri, + message: localize('trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + }); + if (!trusted) { + this._workspacePicker.removeFromRecents(folderUri); } - const expr = ContextKeyExpr.deserialize(optionGroup.when); - return !expr || this.contextKeyService.contextMatchesRules(expr); + return !!trusted; } - private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { - const selectedOption = this._selectedOptions.get(optionGroup.id); - if (selectedOption) { - return selectedOption; - } - if (this._newSession.value) { - const sessionOption = this.chatSessionsService.getSessionOption(this._newSession.value.resource, optionGroup.id); - if (!isString(sessionOption)) { - return sessionOption; + private _restoreState(): void { + const draft = this._getDraftState(); + if (draft) { + this._editor?.getModel()?.setValue(draft.inputText); + if (draft.attachments?.length) { + this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); } } - - return optionGroup.items.find((item) => item.default === true); } - private _syncOptionsFromSession(sessionResource: URI): void { - const activeSessionType = this._targetPicker.selectedTarget; - if (!activeSessionType) { - return; - } - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - if (!optionGroups) { - return; + private _getDraftState(): IDraftState | undefined { + const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + if (!raw) { + return undefined; } - for (const optionGroup of optionGroups) { - if (isModelOptionGroup(optionGroup)) { - continue; - } - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); - if (!currentOption) { - continue; - } - let item: IChatSessionProviderOptionItem | undefined; - if (typeof currentOption === 'string') { - item = optionGroup.items.find((m: { id: string }) => m.id === currentOption.trim()); - } else { - item = currentOption; - } - if (item) { - const { locked: _locked, ...unlocked } = item; - this._selectedOptions.set(optionGroup.id, unlocked as IChatSessionProviderOptionItem); - this._updateOptionContextKey(optionGroup.id, item.id); - this._optionEmitters.get(optionGroup.id)?.fire(item); - } + try { + return JSON.parse(raw); + } catch { + return undefined; } } - private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { - let contextKey = this._optionContextKeys.get(optionGroupId); - if (!contextKey) { - const rawKey = new RawContextKey(`chatSessionOption.${optionGroupId}`, ''); - contextKey = rawKey.bindTo(this.contextKeyService); - this._optionContextKeys.set(optionGroupId, contextKey); - } - contextKey.set(optionItemId.trim()); + private _clearDraftState(): void { + this._draftState = { inputText: '', attachments: [] }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(this._draftState), StorageScope.WORKSPACE, StorageTarget.MACHINE); } - private _getOrCreateOptionEmitter(optionGroupId: string): Emitter { - let emitter = this._optionEmitters.get(optionGroupId); - if (!emitter) { - emitter = new Emitter(); - this._optionEmitters.set(optionGroupId, emitter); - this._pickerWidgetDisposables.add(emitter); + saveState(): void { + if (this._draftState) { + const state = { + ...this._draftState, + attachments: this._draftState.attachments.map(IChatRequestVariableEntry.toExport), + }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); } - return emitter; } - private _clearExtensionPickers(): void { - this._pickerWidgetDisposables.clear(); - this._pickerWidgets.clear(); - this._optionEmitters.clear(); - if (this._folderPickerContainer) { - this._folderPickerContainer.style.display = 'none'; - } - if (this._extensionPickersLeftContainer) { - this._extensionPickersLeftContainer.style.display = 'none'; - } - if (this._extensionPickersRightContainer) { - dom.clearNode(this._extensionPickersRightContainer); - } + layout(_height: number, _width: number): void { + this._editor?.layout(); } - // --- Send --- - - private _updateSendButtonState(): void { - if (!this._sendButton) { - return; - } - const hasText = !!this._editor?.getModel()?.getValue().trim(); - this._sendButton.enabled = !this._sending && hasText && !(this._newSession.value?.disabled ?? true); + focusInput(): void { + this._editor?.focus(); } - private _send(): void { - const query = this._editor.getModel()?.getValue().trim(); - const session = this._newSession.value; - if (!query || !session || session.disabled || this._sending) { - return; + /** + * Handles a workspace selection from the workspace picker. + * Requests folder trust if needed and creates a new session. + */ + private async _onWorkspaceSelected(selection: IWorkspaceSelection): Promise { + // Check if the provider's session type requires workspace trust + const sessionTypes = this.sessionsProvidersService.getSessionTypesForProvider(selection.providerId); + const requiresTrust = sessionTypes.some(t => t.requiresWorkspaceTrust); + if (requiresTrust) { + const workspaceUri = selection.workspace.repositories[0]?.uri; + if (workspaceUri && !await this._requestFolderTrust(workspaceUri)) { + return; + } } - session.setQuery(query); - session.setAttachedContext( - this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined - ); - - this._sending = true; - this._editor.updateOptions({ readOnly: true }); - this._updateSendButtonState(); - this._updateInputLoadingState(); - - this.sessionsManagementService.sendRequestForNewSession( - session.resource - ).then(() => { - // Release ref without disposing - the service owns disposal - this._newSession.clearAndLeak(); - this._newSessionListener.clear(); - this._contextAttachments.clear(); - }, e => { - this.logService.error('Failed to send request:', e); - }).finally(() => { - this._sending = false; - this._editor.updateOptions({ readOnly: false }); - this._updateSendButtonState(); - this._updateInputLoadingState(); - }); - } - - // --- Layout --- - - layout(_height: number, _width: number): void { - this._editor?.layout(); + this._createNewSession(selection); } - focusInput(): void { - this._editor?.focus(); + prefillInput(text: string): void { + const editor = this._editor; + const model = editor?.getModel(); + if (editor && model) { + model.setValue(text); + const lastLine = model.getLineCount(); + const maxColumn = model.getLineMaxColumn(lastLine); + editor.setPosition({ lineNumber: lastLine, column: maxColumn }); + editor.focus(); + } } - updateAllowedTargets(targets: AgentSessionProviders[]): void { - this._targetPicker.updateAllowedTargets(targets); + sendQuery(text: string): void { + const model = this._editor?.getModel(); + if (model) { + model.setValue(text); + this._send(); + } } } @@ -839,7 +703,6 @@ export class NewChatViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -849,23 +712,10 @@ export class NewChatViewPane extends ViewPane { this._widget = this._register(this.instantiationService.createInstance( NewChatWidget, - { - allowedTargets: this.computeAllowedTargets(), - defaultTarget: AgentSessionProviders.Background, - } satisfies INewChatWidgetOptions, )); this._widget.render(container); this._widget.focusInput(); - - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => { - this._widget?.updateAllowedTargets(this.computeAllowedTargets()); - })); - } - - private computeAllowedTargets(): AgentSessionProviders[] { - const targets: AgentSessionProviders[] = [AgentSessionProviders.Background, AgentSessionProviders.Cloud]; - return targets; } protected override layoutBody(height: number, width: number): void { @@ -878,37 +728,29 @@ export class NewChatViewPane extends ViewPane { this._widget?.focusInput(); } + prefillInput(text: string): void { + this._widget?.prefillInput(text); + } + + sendQuery(text: string): void { + this._widget?.sendQuery(text); + } + override setVisible(visible: boolean): void { super.setVisible(visible); if (visible) { this._widget?.focusInput(); } } -} -// #endregion + override saveState(): void { + this._widget?.saveState(); + } -/** - * Check whether an option group represents the model picker. - * The convention is `id: 'models'` but extensions may use different IDs - * per session type, so we also fall back to name matching. - */ -function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { - if (group.id === 'models') { - return true; + override dispose(): void { + this._widget?.saveState(); + super.dispose(); } - const nameLower = group.name.toLowerCase(); - return nameLower === 'model' || nameLower === 'models'; } -/** - * Check whether an option group represents a repository or folder picker. - * These are placed on the right side of the pickers row. - */ -function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean { - const idLower = group.id.toLowerCase(); - const nameLower = group.name.toLowerCase(); - return idLower === 'repositories' || idLower === 'folders' || - nameLower === 'repository' || nameLower === 'repositories' || - nameLower === 'folder' || nameLower === 'folders'; -} +// #endregion diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 2e601215b535a..336e904b36745 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -3,237 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IsolationMode } from './sessionTargetPicker.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; - -export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled'; - -/** - * A new session represents a session being configured before the first - * request is sent. It holds the user's selections (repoUri, isolationMode) - * and fires a single event when any property changes. - */ -export interface INewSession extends IDisposable { - readonly resource: URI; - readonly target: AgentSessionProviders; - readonly repoUri: URI | undefined; - readonly isolationMode: IsolationMode; - readonly branch: string | undefined; - readonly modelId: string | undefined; - readonly query: string | undefined; - readonly attachedContext: IChatRequestVariableEntry[] | undefined; - readonly selectedOptions: ReadonlyMap; - readonly disabled: boolean; - readonly onDidChange: Event; - setRepoUri(uri: URI): void; - setIsolationMode(mode: IsolationMode): void; - setBranch(branch: string | undefined): void; - setModelId(modelId: string | undefined): void; - setQuery(query: string): void; - setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void; - setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void; -} - -const REPOSITORY_OPTION_ID = 'repository'; -const BRANCH_OPTION_ID = 'branch'; -const ISOLATION_OPTION_ID = 'isolation'; - -/** - * Local new session for Background agent sessions. - * Fires `onDidChange` for both `repoUri` and `isolationMode` changes. - * Notifies the extension service with session options for each property change. - */ -export class LocalNewSession extends Disposable implements INewSession { - - private _repoUri: URI | undefined; - private _isolationMode: IsolationMode = 'worktree'; - private _branch: string | undefined; - private _modelId: string | undefined; - private _query: string | undefined; - private _attachedContext: IChatRequestVariableEntry[] | undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - readonly target = AgentSessionProviders.Background; - readonly selectedOptions = new Map(); - - get repoUri(): URI | undefined { return this._repoUri; } - get isolationMode(): IsolationMode { return this._isolationMode; } - get branch(): string | undefined { return this._branch; } - get modelId(): string | undefined { return this._modelId; } - get query(): string | undefined { return this._query; } - get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } - get disabled(): boolean { - if (!this._repoUri) { - return true; - } - if (this._isolationMode === 'worktree' && !this._branch) { - return true; - } - return false; - } - - constructor( - readonly resource: URI, - defaultRepoUri: URI | undefined, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, - ) { - super(); - if (defaultRepoUri) { - this._repoUri = defaultRepoUri; - this.setOption(REPOSITORY_OPTION_ID, defaultRepoUri.fsPath); - } - } - - setRepoUri(uri: URI): void { - this._repoUri = uri; - this._isolationMode = 'workspace'; - this._branch = undefined; - this._onDidChange.fire('repoUri'); - this._onDidChange.fire('disabled'); - this.setOption(REPOSITORY_OPTION_ID, uri.fsPath); - } - - setIsolationMode(mode: IsolationMode): void { - if (this._isolationMode !== mode) { - this._isolationMode = mode; - this._onDidChange.fire('isolationMode'); - this._onDidChange.fire('disabled'); - this.setOption(ISOLATION_OPTION_ID, mode); - } - } - - setBranch(branch: string | undefined): void { - if (this._branch !== branch) { - this._branch = branch; - this._onDidChange.fire('branch'); - this._onDidChange.fire('disabled'); - this.setOption(BRANCH_OPTION_ID, branch ?? ''); - } - } - - setModelId(modelId: string | undefined): void { - this._modelId = modelId; - } - - setQuery(query: string): void { - this._query = query; - } - - setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { - this._attachedContext = context; - } - - setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { - if (typeof value === 'string') { - this.selectedOptions.set(optionId, { id: value, name: value }); - } else { - this.selectedOptions.set(optionId, value); - } - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify session option ${optionId} change:`, err)); - } -} +export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled' | 'agent'; /** - * Remote new session for Cloud agent sessions. - * Fires `onDidChange` and notifies the extension service when `repoUri` changes. - * Ignores `isolationMode` (not relevant for cloud). + * Represents a resolved option group with its current selected value. */ -export class RemoteNewSession extends Disposable implements INewSession { - - private _repoUri: URI | undefined; - private _isolationMode: IsolationMode = 'worktree'; - private _modelId: string | undefined; - private _query: string | undefined; - private _attachedContext: IChatRequestVariableEntry[] | undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - readonly selectedOptions = new Map(); - - get repoUri(): URI | undefined { return this._repoUri; } - get isolationMode(): IsolationMode { return this._isolationMode; } - get branch(): string | undefined { return undefined; } - get modelId(): string | undefined { return this._modelId; } - get query(): string | undefined { return this._query; } - get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } - get disabled(): boolean { - return !this._repoUri && !this._hasRepositoryOption(); - } - - constructor( - readonly resource: URI, - readonly target: AgentSessionProviders, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, - ) { - super(); - - // Listen for extension-driven option group and session option changes - this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { - this._onDidChange.fire('options'); - })); - this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { - if (isEqual(this.resource, e)) { - this._onDidChange.fire('options'); - } - })); - } - - setRepoUri(uri: URI): void { - this._repoUri = uri; - this._onDidChange.fire('repoUri'); - this._onDidChange.fire('disabled'); - this.setOption('repository', uri.fsPath); - } - - setIsolationMode(_mode: IsolationMode): void { - // No-op for remote sessions — isolation mode is not relevant - } - - setBranch(_branch: string | undefined): void { - // No-op for remote sessions — branch is not relevant - } - - setModelId(modelId: string | undefined): void { - this._modelId = modelId; - } - - setQuery(query: string): void { - this._query = query; - } - - setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { - this._attachedContext = context; - } - - setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { - if (typeof value !== 'string') { - this.selectedOptions.set(optionId, value); - } - this._onDidChange.fire('options'); - this._onDidChange.fire('disabled'); - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); - } - - private _hasRepositoryOption(): boolean { - return this.selectedOptions.has('repositories'); - } +export interface ISessionOptionGroup { + readonly group: IChatSessionProviderOptionGroup; + readonly value: IChatSessionProviderOptionItem | undefined; } diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 69c0e8e2497dd..22c9e661fb679 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -6,23 +6,249 @@ import { PromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.js'; import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.js'; import { Event } from '../../../../base/common/event.js'; -import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { basename, dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { HOOKS_SOURCE_FOLDER, SKILL_FILENAME, getCleanPromptName } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IAgentSkill, IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../../chat/common/builtinPromptsStorage.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; + +/** URI root for built-in prompts bundled with the Sessions app. */ +export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts'); + +/** URI root for built-in skills bundled with the Sessions app. */ +export const BUILTIN_SKILLS_URI = FileAccess.asFileUri('vs/sessions/skills'); export class AgenticPromptsService extends PromptsService { + private _copilotRoot: URI | undefined; + private _builtinPromptsCache: Map> | undefined; + private _builtinSkillsCache: Promise | undefined; + protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); } + + private getCopilotRoot(): URI { + if (!this._copilotRoot) { + const pathService = this.instantiationService.invokeFunction(accessor => accessor.get(IPathService)); + this._copilotRoot = joinPath(pathService.userHome({ preferLocal: true }), '.copilot'); + } + return this._copilotRoot; + } + + /** + * Returns built-in prompt files bundled with the Sessions app. + */ + private async getBuiltinPromptFiles(type: PromptsType): Promise { + if (type !== PromptsType.prompt) { + return []; + } + + if (!this._builtinPromptsCache) { + this._builtinPromptsCache = new Map(); + } + + let cached = this._builtinPromptsCache.get(type); + if (!cached) { + cached = this.discoverBuiltinPrompts(type); + this._builtinPromptsCache.set(type, cached); + } + return cached; + } + + private async discoverBuiltinPrompts(type: PromptsType): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + const promptsDir = FileAccess.asFileUri('vs/sessions/prompts'); + try { + const stat = await fileService.resolve(promptsDir); + if (!stat.children) { + return []; + } + return stat.children + .filter(child => !child.isDirectory && child.name.endsWith('.prompt.md')) + .map(child => ({ uri: child.resource, storage: BUILTIN_STORAGE, type })); + } catch { + return []; + } + } + + //#region Built-in Skills + + /** + * Returns built-in skill metadata, discovering and parsing SKILL.md files + * bundled in the `vs/sessions/skills/` directory. + */ + private async getBuiltinSkills(): Promise { + if (!this._builtinSkillsCache) { + this._builtinSkillsCache = this.discoverBuiltinSkills(); + } + return this._builtinSkillsCache; + } + + /** + * Discovers built-in skills from `vs/sessions/skills/{name}/SKILL.md`. + * Each subdirectory containing a SKILL.md is treated as a skill. + */ + private async discoverBuiltinSkills(): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + try { + const stat = await fileService.resolve(BUILTIN_SKILLS_URI); + if (!stat.children) { + return []; + } + + const skills: IAgentSkill[] = []; + for (const child of stat.children) { + if (!child.isDirectory) { + continue; + } + const skillFileUri = joinPath(child.resource, SKILL_FILENAME); + try { + const parsed = await this.parseNew(skillFileUri, CancellationToken.None); + const rawName = parsed.header?.name; + const rawDescription = parsed.header?.description; + if (!rawName || !rawDescription) { + continue; + } + const name = sanitizeSkillText(rawName, 64); + const description = sanitizeSkillText(rawDescription, 1024); + const folderName = basename(child.resource); + if (name !== folderName) { + continue; + } + skills.push({ + uri: skillFileUri, + storage: BUILTIN_STORAGE as PromptsStorage, + name, + description, + disableModelInvocation: parsed.header?.disableModelInvocation === true, + userInvocable: parsed.header?.userInvocable !== false, + }); + } catch (e) { + this.logger.warn(`[discoverBuiltinSkills] Failed to parse built-in skill: ${skillFileUri}`, e instanceof Error ? e.message : String(e)); + } + } + return skills; + } catch { + return []; + } + } + + /** + * Returns built-in skill file paths for listing in the UI. + */ + private async getBuiltinSkillPaths(): Promise { + const skills = await this.getBuiltinSkills(); + return skills.map(s => ({ + uri: s.uri, + storage: BUILTIN_STORAGE, + type: PromptsType.skill, + name: s.name, + description: s.description, + })); + } + + /** + * Override to include built-in skills, appending them with lowest priority. + * Skills from any other source (workspace, user, extension, internal) take precedence. + */ + public override async findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise { + const baseResult = await super.findAgentSkills(token, sessionResource); + if (baseResult === undefined) { + return undefined; + } + + const builtinSkills = await this.getBuiltinSkills(); + if (builtinSkills.length === 0) { + return baseResult; + } + + // Collect names already present from other sources + const existingNames = new Set(baseResult.map(s => s.name)); + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name) && !disabledSkills.has(s.uri)); + if (nonOverridden.length === 0) { + return baseResult; + } + + return [...baseResult, ...nonOverridden]; + } + + //#endregion + + /** + * Override to include built-in prompts and built-in skills, filtering out + * those overridden by user or workspace items with the same name. + */ + public override async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { + const baseResults = await super.listPromptFiles(type, token); + + let builtinItems: readonly IBuiltinPromptPath[]; + if (type === PromptsType.skill) { + builtinItems = await this.getBuiltinSkillPaths(); + } else { + builtinItems = await this.getBuiltinPromptFiles(type); + } + if (builtinItems.length === 0) { + return baseResults; + } + + // Collect names of user/workspace items to detect overrides + const overriddenNames = new Set(); + for (const p of baseResults) { + if (p.storage === PromptsStorage.local || p.storage === PromptsStorage.user) { + overriddenNames.add(type === PromptsType.skill ? basename(dirname(p.uri)) : getCleanPromptName(p.uri)); + } + } + + const nonOverridden = builtinItems.filter( + p => !overriddenNames.has(type === PromptsType.skill ? basename(dirname(p.uri)) : getCleanPromptName(p.uri)) + ); + // Built-in items use BUILTIN_STORAGE ('builtin') which is not in the + // core IPromptPath union but is handled by the sessions UI layer. + return [...baseResults, ...nonOverridden] as readonly IPromptPath[]; + } + + public override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + if (storage === BUILTIN_STORAGE) { + if (type === PromptsType.skill) { + return this.getBuiltinSkillPaths() as Promise; + } + return this.getBuiltinPromptFiles(type) as Promise; + } + return super.listPromptFilesForStorage(type, storage, token); + } + + /** + * Override to use ~/.copilot as the user-level source folder for creation, + * instead of the VS Code profile's promptsHome. + */ + public override async getSourceFolders(type: PromptsType): Promise { + const folders = await super.getSourceFolders(type); + const copilotRoot = this.getCopilotRoot(); + // Replace any user-storage folders with the CLI-accessible ~/.copilot root + return folders.map(folder => { + if (folder.storage === PromptsStorage.user) { + const subfolder = getCliUserSubfolder(type); + return subfolder + ? { ...folder, uri: joinPath(copilotRoot, subfolder) } + : folder; + } + return folder; + }); + } } class AgenticPromptFilesLocator extends PromptFilesLocator { @@ -36,7 +262,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { @IUserDataProfileService userDataService: IUserDataProfileService, @ILogService logService: ILogService, @IPathService pathService: IPathService, - @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, + @IWorkspaceTrustManagementService workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IAICustomizationWorkspaceService private readonly customizationWorkspaceService: IAICustomizationWorkspaceService, ) { super( fileService, @@ -46,7 +273,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { searchService, userDataService, logService, - pathService + pathService, + workspaceTrustManagementService ); } @@ -64,7 +292,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } protected override onDidChangeWorkspaceFolders(): Event { - return Event.fromObservableLight(this.activeSessionService.activeSession); + return Event.fromObservableLight(this.customizationWorkspaceService.activeProjectRoot); } public override async getHookSourceFolders(): Promise { @@ -77,8 +305,7 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } private getActiveWorkspaceFolder(): IWorkspaceFolder | undefined { - const session = this.activeSessionService.getActiveSession(); - const root = session?.worktree ?? session?.repository; + const root = this.customizationWorkspaceService.getActiveProjectRoot(); if (!root) { return undefined; } @@ -91,3 +318,28 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { } } +/** + * Returns the subfolder name under ~/.copilot/ for a given customization type. + * Used to determine the CLI-accessible user creation target. + * + * Prompts are a VS Code concept and use the standard profile promptsHome, + * so they are intentionally excluded here. + */ +function getCliUserSubfolder(type: PromptsType): string | undefined { + switch (type) { + case PromptsType.instructions: return 'instructions'; + case PromptsType.skill: return 'skills'; + case PromptsType.agent: return 'agents'; + default: return undefined; + } +} + +/** + * Strips XML tags and truncates to the given max length. + * Matches the sanitization applied by PromptsService for other skill sources. + */ +function sanitizeSkillText(text: string, maxLength: number): string { + const sanitized = text.replace(/<[^>]+>/g, ''); + return sanitized.length > maxLength ? sanitized.substring(0, maxLength) : sanitized; +} + diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts new file mode 100644 index 0000000000000..0ac9de839be2a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; + +const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; +const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo'; +const STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; +const MAX_RECENT_REPOS = 10; +const FILTER_THRESHOLD = 10; + +interface IRepoItem { + readonly id: string; + readonly name: string; +} + +/** + * A self-contained widget for selecting the repository in cloud sessions. + * Uses the `github.copilot.chat.cloudSessions.openRepository` command for + * browsing repositories. Manages recently used repos in storage. + * Behaves like FolderPicker: trigger button with dropdown, storage persistence, + * recently used list with remove buttons. + */ +export class RepoPicker extends Disposable { + + private readonly _onDidSelectRepo = this._register(new Emitter()); + readonly onDidSelectRepo: Event = this._onDidSelectRepo.event; + + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + private _selectedRepo: IRepoItem | undefined; + private _recentlyPickedRepos: IRepoItem[] = []; + + get selectedRepo(): string | undefined { + return this._selectedRepo?.id; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IStorageService private readonly storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + // Restore last picked repo + try { + const last = this.storageService.get(STORAGE_KEY_LAST_REPO, StorageScope.PROFILE); + if (last) { + this._selectedRepo = JSON.parse(last); + } + } catch { /* ignore */ } + + // Restore recently picked repos + try { + const stored = this.storageService.get(STORAGE_KEY_RECENT_REPOS, StorageScope.PROFILE); + if (stored) { + this._recentlyPickedRepos = JSON.parse(stored); + } + } catch { /* ignore */ } + } + + /** + * Renders the repo picker trigger button into the given container. + * Returns the container element. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + /** + * Shows the repo picker dropdown anchored to the trigger element. + */ + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.id === 'browse') { + this._browseForRepo(); + } else { + this._selectRepo(item); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'repoPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('repoPicker.ariaLabel', "Repository Picker"), + }, + showFilter ? { showFilter: true, filterPlaceholder: localize('repoPicker.filter', "Filter repositories...") } : undefined, + ); + } + + /** + * Programmatically set the selected repository. + */ + setSelectedRepo(repoPath: string): void { + this._selectRepo({ id: repoPath, name: repoPath }); + } + + /** + * Clears the selected repository. + */ + clearSelection(): void { + this._selectedRepo = undefined; + this._updateTriggerLabel(); + } + + private _selectRepo(item: IRepoItem): void { + this._selectedRepo = item; + this._addToRecentlyPicked(item); + this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE); + this._updateTriggerLabel(); + this._onDidSelectRepo.fire(item.id); + } + + private async _browseForRepo(): Promise { + try { + const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (result) { + this._selectRepo({ id: result, name: result }); + } + } catch { + // command was cancelled or failed — nothing to do + } + } + + private _addToRecentlyPicked(item: IRepoItem): void { + this._recentlyPickedRepos = [ + { id: item.id, name: item.name }, + ...this._recentlyPickedRepos.filter(r => r.id !== item.id), + ].slice(0, MAX_RECENT_REPOS); + this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private _buildItems(): IActionListItem[] { + const seenIds = new Set(); + const items: IActionListItem[] = []; + + // Currently selected (shown first, checked) + if (this._selectedRepo) { + seenIds.add(this._selectedRepo.id); + items.push({ + kind: ActionListItemKind.Action, + label: this._selectedRepo.name, + group: { title: '', icon: Codicon.repo }, + item: this._selectedRepo, + }); + } + + // Recently picked repos (sorted by name) + const dedupedRepos = this._recentlyPickedRepos.filter(r => !seenIds.has(r.id)); + dedupedRepos.sort((a, b) => a.name.localeCompare(b.name)); + for (const repo of dedupedRepos) { + seenIds.add(repo.id); + items.push({ + kind: ActionListItemKind.Action, + label: repo.name, + group: { title: '', icon: Codicon.repo }, + item: repo, + onRemove: () => this._removeRepo(repo.id), + }); + } + + // Separator + Browse... + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRepo', "Browse..."), + group: { title: '', icon: Codicon.search }, + item: { id: 'browse', name: localize('browseRepo', "Browse...") }, + }); + + return items; + } + + private _removeRepo(repoId: string): void { + this._recentlyPickedRepos = this._recentlyPickedRepos.filter(r => r.id !== repoId); + this.storageService.store(STORAGE_KEY_RECENT_REPOS, JSON.stringify(this._recentlyPickedRepos), StorageScope.PROFILE, StorageTarget.MACHINE); + + // Re-show picker with updated items + this.actionWidgetService.hide(); + this.showPicker(); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const label = this._selectedRepo?.name ?? localize('pickRepo', "Pick Repository"); + + dom.append(this._triggerElement, renderIcon(Codicon.repo)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + } + +} diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 765f8466e2f92..9e15ce6b4a61b 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -3,29 +3,50 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Action, IAction } from '../../../../base/common/actions.js'; import { equals } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; -import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { MenuId, registerAction2, Action2, MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IsActiveSessionBackgroundProviderContext, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionData, SessionStatus } from '../../sessions/common/sessionData.js'; import { Menus } from '../../../browser/menus.js'; -import { ISessionsConfigurationService, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { INonSessionTaskEntry, ISessionsConfigurationService, ISessionTaskWithTarget, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; - +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.js'; +import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; // Menu IDs - exported for use in auxiliary bar part export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdown'); // Action IDs -const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; +const RUN_SCRIPT_ACTION_PRIMARY_ID = 'workbench.action.agentSessions.runScriptPrimary'; const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; +const GENERATE_RUN_ACTION_ID = 'workbench.action.agentSessions.generateRunAction'; function getTaskDisplayLabel(task: ITaskEntry): string { if (task.label && task.label.length > 0) { @@ -43,12 +64,42 @@ function getTaskDisplayLabel(task: ITaskEntry): string { return ''; } +function getTaskCommandPreview(task: ITaskEntry): string { + if (task.command && task.command.length > 0) { + return task.command; + } + if (task.script && task.script.length > 0) { + return localize('npmTaskCommandPreview', "npm run {0}", task.script); + } + if (task.task && task.task.toString().length > 0) { + return task.task.toString(); + } + return getTaskDisplayLabel(task); +} + +function getPrimaryTask(tasks: readonly ISessionTaskWithTarget[], pinnedTaskLabel: string | undefined): ISessionTaskWithTarget | undefined { + if (tasks.length === 0) { + return undefined; + } + + if (pinnedTaskLabel) { + const pinnedTask = tasks.find(task => task.task.label === pinnedTaskLabel); + if (pinnedTask) { + return pinnedTask; + } + } + + return tasks[0]; +} + interface IRunScriptActionContext { - readonly session: IActiveSessionItem; - readonly tasks: readonly ITaskEntry[]; - readonly lastRunTaskLabel: string | undefined; + readonly session: ISessionData; + readonly tasks: readonly ISessionTaskWithTarget[]; + readonly pinnedTaskLabel: string | undefined; } +type TaskConfigurationMode = 'add' | 'configure'; + /** * Workbench contribution that adds a split dropdown action to the auxiliary bar title * for running a task via tasks.json. @@ -61,8 +112,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor( @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IKeybindingService _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IViewsService private readonly _viewsService: IViewsService, + @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, ) { super(); @@ -72,9 +127,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr if (a === b) { return true; } if (!a || !b) { return false; } return a.session === b.session - && a.lastRunTaskLabel === b.lastRunTaskLabel + && a.pinnedTaskLabel === b.pinnedTaskLabel && equals(a.tasks, b.tasks, (t1, t2) => - t1.label === t2.label && t1.command === t2.command); + t1.task.label === t2.task.label + && t1.task.command === t2.task.command + && t1.target === t2.target + && t1.task.runOptions?.runOn === t2.task.runOptions?.runOn); } }, reader => { const activeSession = this._activeSessionService.activeSession.read(reader); @@ -83,66 +141,90 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } const tasks = this._sessionsConfigService.getSessionTasks(activeSession).read(reader); - const lastRunTaskLabel = this._sessionsConfigService.getLastRunTaskLabel(activeSession.repository).read(reader); - return { session: activeSession, tasks, lastRunTaskLabel }; + const repo = activeSession.workspace.read(reader)?.repositories[0]; + const pinnedTaskLabel = this._sessionsConfigService.getPinnedTaskLabel(repo?.uri).read(reader); + return { session: activeSession, tasks, pinnedTaskLabel }; }).recomputeInitiallyAndOnChange(this._store); + this._registerActionViewItemProvider(); this._registerActions(); } + private _registerActionViewItemProvider(): void { + const that = this; + this._register(this._actionViewItemService.register( + Menus.TitleBarSessionMenu, + RunScriptDropdownMenuId, + (action, options, instantiationService) => { + if (!(action instanceof SubmenuItemAction)) { + return undefined; + } + return instantiationService.createInstance( + RunScriptActionViewItem, + action, + options, + that._activeRunState, + (session: ISessionData) => that._showConfigureQuickPick(session), + (session: ISessionData, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => that._showCustomCommandInput(session, existingTask, mode), + ); + }, + )); + } + private _registerActions(): void { const that = this; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_SCRIPT_ACTION_PRIMARY_ID, + title: { value: localize('runPrimaryTask', 'Run Primary Task'), original: 'Run Primary Task' }, + icon: Codicon.play, + category: SessionsCategories.Sessions, + f1: true, + }); + } + + async run(): Promise { + const activeState = that._activeRunState.get(); + if (!activeState) { + return; + } + + const { tasks, session } = activeState; + if (tasks.length === 0) { + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } + return; + } + + const primaryTask = getPrimaryTask(tasks, activeState.pinnedTaskLabel); + if (!primaryTask) { + return; + } + await that._sessionsConfigService.runTask(primaryTask.task, session); + } + })); + this._register(autorun(reader => { const activeState = this._activeRunState.read(reader); if (!activeState) { return; } - const { tasks, session, lastRunTaskLabel } = activeState; - const configureScriptPrecondition = session.worktree ?? session.repository ? ContextKeyExpr.true() : ContextKeyExpr.false(); - - const mruIndex = lastRunTaskLabel !== undefined - ? tasks.findIndex(t => t.label === lastRunTaskLabel) - : -1; - - if (tasks.length > 0) { - // Register an action for each session task - for (let i = 0; i < tasks.length; i++) { - const task = tasks[i]; - const actionId = `${RUN_SCRIPT_ACTION_ID}.${i}`; - - reader.store.add(registerAction2(class extends Action2 { - constructor() { - super({ - id: actionId, - title: getTaskDisplayLabel(task), - tooltip: localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)), - icon: Codicon.play, - category: SessionsCategories.Sessions, - menu: [{ - id: RunScriptDropdownMenuId, - group: '0_scripts', - order: i === mruIndex ? -1 : i, - }] - }); - } - - async run(): Promise { - await that._sessionsConfigService.runTask(task, session); - } - })); - } - } + const { session, tasks } = activeState; + const repo = session.workspace.read(reader)?.repositories[0]; + const configureScriptPrecondition = repo?.workingDirectory ?? repo?.uri ? ContextKeyExpr.true() : ContextKeyExpr.false(); - // Configure run action (always shown in dropdown) reader.store.add(registerAction2(class extends Action2 { constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Run Action..."), + title: localize2('configureDefaultRunAction', "Add Task..."), category: SessionsCategories.Sessions, - icon: Codicon.play, + icon: Codicon.add, precondition: configureScriptPrecondition, menu: [{ id: RunScriptDropdownMenuId, @@ -153,18 +235,47 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } async run(): Promise { - await that._showConfigureQuickPick(session); + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } + } + })); + + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: GENERATE_RUN_ACTION_ID, + title: localize2('generateRunAction', "Generate New Task..."), + category: SessionsCategories.Sessions, + precondition: IsActiveSessionBackgroundProviderContext, + menu: [{ + id: RunScriptDropdownMenuId, + group: tasks.length === 0 ? 'navigation' : '1_configure', + order: 1 + }] + }); + } + + async run(): Promise { + const status = session.status.read(undefined); + if (status === SessionStatus.Untitled) { + const viewPane = that._viewsService.getViewWithId(SessionsViewId); + viewPane?.sendQuery('/generate-run-commands'); + } else { + const widget = that._chatWidgetService.getWidgetBySessionResource(session.resource); + await widget?.acceptInput('/generate-run-commands'); + } } })); })); } - private async _showConfigureQuickPick(session: IActiveSessionItem): Promise { + private async _showConfigureQuickPick(session: ISessionData): Promise { const nonSessionTasks = await this._sessionsConfigService.getNonSessionTasks(session); if (nonSessionTasks.length === 0) { // No existing tasks, go straight to custom command input - await this._showCustomCommandInput(session); - return; + return this._showCustomCommandInput(session); } interface ITaskPickItem extends IQuickPickItem { @@ -176,117 +287,461 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr items.push({ type: 'separator', label: localize('custom', "Custom") }); items.push({ - label: localize('enterCustomCommand', "Enter Custom Command..."), + label: localize('createNewTask', "Create new Task..."), description: localize('enterCustomCommandDesc', "Create a new shell task"), }); if (nonSessionTasks.length > 0) { items.push({ type: 'separator', label: localize('existingTasks', "Existing Tasks") }); - for (const task of nonSessionTasks) { + for (const { task, target } of nonSessionTasks) { items.push({ label: getTaskDisplayLabel(task), description: task.command, task, - source: 'workspace', + source: target, }); } } const picked = await this._quickInputService.pick(items, { - placeHolder: localize('pickRunAction', "Select a task or enter a custom command"), + placeHolder: localize('pickRunAction', "Select or create a task"), }); if (!picked) { - return; + return undefined; } const pickedItem = picked as ITaskPickItem; if (pickedItem.task) { - // Existing task — set inSessions: true - await this._sessionsConfigService.addTaskToSessions(pickedItem.task, session, pickedItem.source ?? 'workspace'); + return this._showCustomCommandInput(session, { task: pickedItem.task, target: pickedItem.source ?? 'workspace' }); } else { // Custom command path - await this._showCustomCommandInput(session); + return this._showCustomCommandInput(session); } } - private async _showCustomCommandInput(session: IActiveSessionItem): Promise { - const command = await this._quickInputService.input({ - placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), - prompt: localize('enterCommandPrompt', "This command will be run as a task in the integrated terminal") - }); - - if (!command) { - return; + private async _showCustomCommandInput(session: ISessionData, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { + const taskConfiguration = await this._showCustomCommandWidget(session, existingTask, mode); + if (!taskConfiguration) { + return undefined; } - const target = await this._pickStorageTarget(session); - if (!target) { - return; + if (existingTask) { + if (mode === 'configure') { + const newLabel = taskConfiguration.label?.trim() || existingTask.task.label || taskConfiguration.command; + + let updatedTask: ITaskEntry = { + ...existingTask.task, + label: newLabel, + inSessions: true, + }; + + if (taskConfiguration.command && existingTask.task.command !== undefined) { + updatedTask = { + ...updatedTask, + command: taskConfiguration.command, + }; + } + + if (taskConfiguration.runOn) { + updatedTask = { + ...updatedTask, + runOptions: { + ...(existingTask.task.runOptions ?? {}), + runOn: taskConfiguration.runOn, + }, + }; + } + + await this._sessionsConfigService.updateTask(existingTask.task.label, updatedTask, session, existingTask.target, taskConfiguration.target); + return updatedTask; + } + + await this._sessionsConfigService.addTaskToSessions(existingTask.task, session, existingTask.target, { runOn: taskConfiguration.runOn ?? 'default' }); + return { + ...existingTask.task, + inSessions: true, + ...(taskConfiguration.runOn ? { runOptions: { runOn: taskConfiguration.runOn } } : {}), + }; } - await this._sessionsConfigService.createAndAddTask(command, session, target); + return this._sessionsConfigService.createAndAddTask( + taskConfiguration.label, + taskConfiguration.command, + session, + taskConfiguration.target, + taskConfiguration.runOn ? { runOn: taskConfiguration.runOn } : undefined + ); } - private async _pickStorageTarget(session: IActiveSessionItem): Promise { - const hasWorktree = !!session.worktree; - const hasRepository = !!session.repository; + private _showCustomCommandWidget(session: ISessionData, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add'): Promise { + const repo = session.workspace.get()?.repositories[0]; + const workspaceTargetDisabledReason = !(repo?.workingDirectory ?? repo?.uri) + ? localize('workspaceStorageUnavailableTooltip', "Workspace storage is unavailable for this session") + : undefined; + const isConfigureMode = mode === 'configure'; + + return new Promise(resolve => { + const disposables = new DisposableStore(); + let settled = false; + + const quickWidget = disposables.add(this._quickInputService.createQuickWidget()); + quickWidget.title = isConfigureMode + ? localize('configureActionWidgetTitle', "Configure Task...") + : existingTask + ? localize('addExistingActionWidgetTitle', "Add Existing Task...") + : localize('addActionWidgetTitle', "Add Task..."); + quickWidget.description = isConfigureMode + ? localize('configureActionWidgetDescription', "Update how this task is named, saved, and run") + : existingTask + ? localize('addExistingActionWidgetDescription', "Enable an existing task for sessions and configure when it should run") + : localize('addActionWidgetDescription', "Create a shell task and configure how it should be saved and run"); + quickWidget.ignoreFocusOut = true; + const widget = disposables.add(new RunScriptCustomTaskWidget({ + label: existingTask?.task.label, + labelDisabledReason: existingTask && !isConfigureMode ? localize('existingTaskLabelLocked', "This name comes from an existing task and cannot be changed here.") : undefined, + command: existingTask ? getTaskCommandPreview(existingTask.task) : undefined, + commandDisabledReason: existingTask && !isConfigureMode ? localize('existingTaskCommandLocked', "This command comes from an existing task and cannot be changed here.") : undefined, + target: existingTask?.target, + targetDisabledReason: existingTask && !isConfigureMode ? localize('existingTaskTargetLocked', "This existing task cannot be moved between workspace and user storage.") : workspaceTargetDisabledReason, + runOn: existingTask?.task.runOptions?.runOn === 'worktreeCreated' ? 'worktreeCreated' : undefined, + mode: isConfigureMode ? 'configure' : existingTask ? 'add-existing' : 'add', + })); + quickWidget.widget = widget.domNode; - interface IStorageTargetItem extends IQuickPickItem { - target: TaskStorageTarget; - } + const complete = (result: IRunScriptCustomTaskWidgetResult | undefined) => { + if (settled) { + return; + } + settled = true; + resolve(result); + quickWidget.hide(); + }; + + disposables.add(widget.onDidSubmit(result => complete(result))); + disposables.add(widget.onDidCancel(() => complete(undefined))); + disposables.add(quickWidget.onDidHide(() => { + if (!settled) { + settled = true; + resolve(undefined); + } + disposables.dispose(); + })); + + quickWidget.show(); + widget.focus(); + }); + } +} + +/** + * Split-button action view item for the run script picker in the sessions titlebar. + * The primary button runs the pinned task, or the first task if none is pinned. + * The dropdown arrow opens a custom action widget with categories and per-item + * toolbar actions (pin, configure, remove). + */ +class RunScriptActionViewItem extends BaseActionViewItem { - const items: IStorageTargetItem[] = [ + private readonly _primaryActionAction: Action; + private readonly _primaryAction: ActionViewItem; + private readonly _dropdown: ChevronActionWidgetDropdown; + + constructor( + action: IAction, + _options: IActionViewItemOptions, + private readonly _activeRunState: IObservable, + private readonly _showConfigureQuickPick: (session: ISessionData) => Promise, + private readonly _showCustomCommandInput: (session: ISessionData, existingTask: INonSessionTaskEntry, mode?: TaskConfigurationMode) => Promise, + @ICommandService private readonly _commandService: ICommandService, + @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, + @IContextKeyService contextKeyService: IContextKeyService, + @ITelemetryService telemetryService: ITelemetryService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IViewsService private readonly _viewsService: IViewsService, + ) { + super(undefined, action); + + const state = this._activeRunState.get(); + const hasTasks = state && state.tasks.length > 0; + + // Primary action button - runs the pinned task (or first task when none is pinned) + this._primaryActionAction = this._register(new Action( + 'agentSessions.runScriptPrimary', + this._getPrimaryActionTooltip(state), + ThemeIcon.asClassName(Codicon.play), + hasTasks, + () => this._commandService.executeCommand(RUN_SCRIPT_ACTION_PRIMARY_ID) + )); + this._primaryAction = this._register(new ActionViewItem(undefined, this._primaryActionAction, { icon: true, label: false })); + + // Update enabled state when tasks change + this._register(autorun(reader => { + const runState = this._activeRunState.read(reader); + this._primaryActionAction.enabled = !!runState && runState.tasks.length > 0; + this._primaryActionAction.label = this._getPrimaryActionTooltip(runState); + })); + + // Dropdown with categorized actions and per-item toolbars + const dropdownAction = this._register(new Action('agentSessions.runScriptDropdown', localize('runDropdown', "More Tasks..."))); + this._dropdown = this._register(new ChevronActionWidgetDropdown( + dropdownAction, { - target: 'user', - label: localize('storeInUserSettings', "User Settings"), - description: localize('storeInUserSettingsDesc', "Available in all sessions"), + actionProvider: { getActions: () => this._getDropdownActions() }, + showItemKeybindings: true, }, - hasWorktree ? { - target: 'workspace', - label: localize('storeInWorkspaceWorktreeSettings', "Workspace (Worktree)"), - description: localize('storeInWorkspaceWorktreeSettingsDesc', "Stored in session worktree"), - } : hasRepository ? { - target: 'workspace', - label: localize('storeInWorkspaceSettings', "Workspace"), - description: localize('storeInWorkspace', "Stored in the workspace"), - } : { - target: 'workspace', - label: localize('storeInWorkspaceSettingsDisable', "Workspace Unavailable"), - description: localize('storeInWorkspaceDisabled', "Stored in the workspace Unavailable"), - disabled: true, - italic: true, + this._actionWidgetService, + this._keybindingService, + contextKeyService, + telemetryService, + )); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('monaco-dropdown-with-default'); + + // Primary action button + const primaryContainer = $('.action-container'); + this._primaryAction.render(append(container, primaryContainer)); + this._register(addDisposableListener(primaryContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.RightArrow)) { + this._primaryAction.blur(); + this._dropdown.focus(); + event.stopPropagation(); + } + })); + + // Dropdown arrow button + const dropdownContainer = $('.dropdown-action-container'); + this._dropdown.render(append(container, dropdownContainer)); + this._register(addDisposableListener(dropdownContainer, EventType.KEY_DOWN, (e: KeyboardEvent) => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.LeftArrow)) { + this._dropdown.setFocusable(false); + this._primaryAction.focus(); + event.stopPropagation(); } - ]; - - return new Promise(resolve => { - const picker = this._quickInputService.createQuickPick({ useSeparators: true }); - picker.placeholder = localize('pickStorageTarget', "Where should this action be saved?"); - picker.items = items; - - picker.onDidAccept(() => { - const selected = picker.activeItems[0]; - if (selected && (selected.target !== 'workspace' || hasWorktree)) { - picker.dispose(); - resolve(selected.target); + })); + } + + override focus(fromRight?: boolean): void { + if (fromRight) { + this._dropdown.focus(); + } else { + this._primaryAction.focus(); + } + } + + override blur(): void { + this._primaryAction.blur(); + this._dropdown.blur(); + } + + override setFocusable(focusable: boolean): void { + this._primaryAction.setFocusable(focusable); + this._dropdown.setFocusable(focusable); + } + + private _getPrimaryActionTooltip(state: IRunScriptActionContext | undefined): string { + if (!state || state.tasks.length === 0) { + return localize('runPrimaryTaskTooltip', "Run Primary Task"); + } + + const primaryTask = getPrimaryTask(state.tasks, state.pinnedTaskLabel)?.task; + if (!primaryTask) { + return localize('runPrimaryTaskTooltip', "Run Primary Task"); + } + + const keybindingLabel = this._keybindingService.lookupKeybinding(RUN_SCRIPT_ACTION_PRIMARY_ID)?.getLabel(); + return keybindingLabel + ? localize('runActionTooltipKeybinding', "{0} ({1})", getTaskDisplayLabel(primaryTask), keybindingLabel) + : getTaskDisplayLabel(primaryTask); + } + + private _getDropdownActions(): IActionWidgetDropdownAction[] { + const state = this._activeRunState.get(); + if (!state) { + return []; + } + + const { tasks, session, pinnedTaskLabel } = state; + const repo = session.workspace.get()?.repositories[0]; + const actions: IActionWidgetDropdownAction[] = []; + + // Category for normal tasks (no header shown) + const defaultCategory = { label: '', order: 0, showHeader: false }; + // Category for worktree-creation tasks + const worktreeCategory = { label: localize('worktreeCreationCategory', "Run on Worktree Creation"), order: 1, showHeader: true }; + // Category for add actions + const addCategory = { label: localize('addActionsCategory', "Add"), order: 2, showHeader: true }; + + for (let i = 0; i < tasks.length; i++) { + const entry = tasks[i]; + const task = entry.task; + const isWorktreeTask = task.runOptions?.runOn === 'worktreeCreated'; + const isPinned = task.label === pinnedTaskLabel; + + const toolbarActions: IAction[] = [ + { + id: `runScript.pin.${i}`, + label: isPinned ? localize('unpinTask', "Unpin") : localize('pinTask', "Pin"), + tooltip: isPinned ? localize('unpinTaskTooltip', "Unpin") : localize('pinTaskTooltip', "Pin"), + class: ThemeIcon.asClassName(isPinned ? Codicon.pinned : Codicon.pin), + enabled: !!repo?.uri, + run: async () => { + this._actionWidgetService.hide(); + this._sessionsConfigService.setPinnedTaskLabel(repo?.uri, isPinned ? undefined : task.label); + } + }, + { + id: `runScript.configure.${i}`, + label: localize('configureTask', "Configure"), + tooltip: localize('configureTask', "Configure"), + class: ThemeIcon.asClassName(Codicon.gear), + enabled: true, + run: async () => { + this._actionWidgetService.hide(); + await this._showCustomCommandInput(session, { task, target: entry.target }, 'configure'); + } + }, + { + id: `runScript.remove.${i}`, + label: localize('removeTask', "Remove"), + tooltip: localize('removeTask', "Remove"), + class: ThemeIcon.asClassName(Codicon.close), + enabled: true, + run: async () => { + this._actionWidgetService.hide(); + await this._sessionsConfigService.removeTask(task.label, session, entry.target); + } } + ]; + + actions.push({ + id: `runScript.task.${i}`, + label: getTaskDisplayLabel(task), + tooltip: '', + hover: { + content: localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)), + position: { hoverPosition: HoverPosition.LEFT } + }, + icon: Codicon.play, + enabled: true, + class: undefined, + category: isWorktreeTask ? worktreeCategory : defaultCategory, + toolbarActions, + run: async () => { + await this._sessionsConfigService.runTask(task, session); + }, }); - picker.onDidHide(() => { - picker.dispose(); - resolve(undefined); - }); - picker.show(); + } + + // "Add Task..." action + const canConfigure = !!(repo?.workingDirectory ?? repo?.uri); + actions.push({ + id: 'runScript.addAction', + label: localize('configureDefaultRunAction', "Add Task..."), + tooltip: '', + hover: { + content: canConfigure + ? localize('addActionTooltip', "Add a new task") + : localize('addActionTooltipDisabled', "Cannot add tasks to this session because workspace storage is unavailable"), + position: { hoverPosition: HoverPosition.LEFT } + }, + icon: Codicon.add, + enabled: canConfigure, + class: undefined, + category: addCategory, + run: async () => { + const task = await this._showConfigureQuickPick(session); + if (task) { + await this._sessionsConfigService.runTask(task, session); + } + }, + }); + + // "Generate New Task..." action + actions.push({ + id: 'runScript.generateAction', + label: localize('generateRunAction', "Generate New Task..."), + tooltip: '', + hover: { + content: localize('generateRunActionTooltip', "Generate a new workspace task"), + position: { hoverPosition: HoverPosition.LEFT }, + }, + icon: Codicon.sparkle, + enabled: true, + class: undefined, + category: addCategory, + run: async () => { + if (session.status.get() === SessionStatus.Untitled) { + const viewPane = this._viewsService.getViewWithId(SessionsViewId); + viewPane?.sendQuery('/generate-run-commands'); + } else { + const widget = this._chatWidgetService.getWidgetBySessionResource(session.resource); + await widget?.acceptInput('/generate-run-commands'); + } + }, }); + + return actions; + } +} + +/** + * {@link ActionWidgetDropdownActionViewItem} that renders a chevron-down icon + * as its label, used as the dropdown arrow in the split button. + */ +class ChevronActionWidgetDropdown extends ActionWidgetDropdownActionViewItem { + protected override renderLabel(element: HTMLElement): IDisposable | null { + element.classList.add('codicon', 'codicon-chevron-down'); + return null; } } -// Register the Run split button submenu on the workbench title bar -MenuRegistry.appendMenuItem(Menus.TitleBarRight, { +// Register the Run split button submenu on the workbench title bar (background sessions only) +MenuRegistry.appendMenuItem(Menus.TitleBarSessionMenu, { submenu: RunScriptDropdownMenuId, isSplitButton: true, title: localize2('run', "Run"), icon: Codicon.play, group: 'navigation', order: 8, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext) +}); + +// Disabled placeholder shown in the titlebar when the active session does not support running scripts +class RunScriptNotAvailableAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.agentSessions.runScript.notAvailable', + title: localize2('run', "Run"), + tooltip: localize('runScriptNotAvailableTooltip', "Run Task is not available for this session type"), + icon: Codicon.play, + precondition: ContextKeyExpr.false(), + menu: [{ + id: Menus.TitleBarSessionMenu, + group: 'navigation', + order: 8, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext.toNegated()) + }] + }); + } + + override run(): void { } +} + +registerAction2(RunScriptNotAvailableAction); + +// Register F5 keybinding at module level to ensure it's in the registry +// before the keybinding resolver is cached. The command handler is +// registered later by RunScriptContribution. +KeybindingsRegistry.registerKeybindingRule({ + id: RUN_SCRIPT_ACTION_PRIMARY_ID, + primary: KeyCode.F5, + weight: KeybindingWeight.WorkbenchContrib + 100, when: IsAuxiliaryWindowContext.toNegated() }); diff --git a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts new file mode 100644 index 0000000000000..5d40dfdef39e3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/runScriptAction.css'; + +import * as dom from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { Radio } from '../../../../base/browser/ui/radio/radio.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { localize } from '../../../../nls.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { TaskStorageTarget } from './sessionsConfigurationService.js'; + +export const WORKTREE_CREATED_RUN_ON = 'worktreeCreated' as const; + +export interface IRunScriptCustomTaskWidgetState { + readonly label?: string; + readonly labelDisabledReason?: string; + readonly command?: string; + readonly commandDisabledReason?: string; + readonly target?: TaskStorageTarget; + readonly targetDisabledReason?: string; + readonly runOn?: typeof WORKTREE_CREATED_RUN_ON; + readonly mode?: 'add' | 'add-existing' | 'configure'; +} + +export interface IRunScriptCustomTaskWidgetResult { + readonly label?: string; + readonly command: string; + readonly target: TaskStorageTarget; + readonly runOn?: typeof WORKTREE_CREATED_RUN_ON; +} + +export class RunScriptCustomTaskWidget extends Disposable { + + readonly domNode: HTMLElement; + + private readonly _labelInput: InputBox; + private readonly _commandInput: InputBox; + private readonly _runOnCheckbox: Checkbox; + private readonly _storageOptions: Radio; + private readonly _submitButton: Button; + private readonly _cancelButton: Button; + private readonly _labelLocked: boolean; + private readonly _commandLocked: boolean; + private readonly _targetLocked: boolean; + private readonly _isExistingTask: boolean; + private readonly _isAddExistingTask: boolean; + private readonly _initialLabel: string; + private readonly _initialCommand: string; + private readonly _initialRunOn: boolean; + private readonly _initialTarget: TaskStorageTarget; + private _selectedTarget: TaskStorageTarget; + + private readonly _onDidSubmit = this._register(new Emitter()); + readonly onDidSubmit: Event = this._onDidSubmit.event; + + private readonly _onDidCancel = this._register(new Emitter()); + readonly onDidCancel: Event = this._onDidCancel.event; + + constructor(state: IRunScriptCustomTaskWidgetState) { + super(); + + this._labelLocked = !!state.labelDisabledReason; + this._commandLocked = !!state.commandDisabledReason; + this._targetLocked = !!state.targetDisabledReason && state.target !== undefined; + this._isExistingTask = state.mode === 'configure'; + this._isAddExistingTask = state.mode === 'add-existing'; + this._selectedTarget = state.target ?? (state.targetDisabledReason ? 'user' : 'workspace'); + this._initialLabel = state.label ?? ''; + this._initialCommand = state.command ?? ''; + this._initialRunOn = state.runOn === WORKTREE_CREATED_RUN_ON; + this._initialTarget = this._selectedTarget; + + this.domNode = dom.$('.run-script-action-widget'); + + const labelSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(labelSection, dom.$('label.run-script-action-label', undefined, localize('labelFieldLabel', "Name"))); + const labelInputContainer = dom.append(labelSection, dom.$('.run-script-action-input')); + this._labelInput = this._register(new InputBox(labelInputContainer, undefined, { + placeholder: localize('enterLabelPlaceholder', "Enter a name for this task (optional)"), + tooltip: state.labelDisabledReason, + ariaLabel: localize('enterLabelAriaLabel', "Task name"), + inputBoxStyles: defaultInputBoxStyles, + })); + this._labelInput.value = state.label ?? ''; + if (state.labelDisabledReason) { + this._labelInput.disable(); + } + + const commandSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(commandSection, dom.$('label.run-script-action-label', undefined, localize('commandFieldLabel', "Command"))); + const commandInputContainer = dom.append(commandSection, dom.$('.run-script-action-input')); + this._commandInput = this._register(new InputBox(commandInputContainer, undefined, { + placeholder: localize('enterCommandPlaceholder', "Enter command (for example, npm run dev)"), + tooltip: state.commandDisabledReason, + ariaLabel: localize('enterCommandAriaLabel', "Task command"), + inputBoxStyles: defaultInputBoxStyles, + })); + this._commandInput.value = state.command ?? ''; + if (state.commandDisabledReason) { + this._commandInput.disable(); + } + + const runOnSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(runOnSection, dom.$('div.run-script-action-label', undefined, localize('runOptionsLabel', "Run Options"))); + const runOnRow = dom.append(runOnSection, dom.$('.run-script-action-option-row')); + this._runOnCheckbox = this._register(new Checkbox(localize('runOnWorktreeCreated', "Run When Worktree Is Created"), state.runOn === WORKTREE_CREATED_RUN_ON, defaultCheckboxStyles)); + runOnRow.appendChild(this._runOnCheckbox.domNode); + const runOnText = dom.append(runOnRow, dom.$('span.run-script-action-option-text', undefined, localize('runOnWorktreeCreatedDescription', "Automatically run this task when the session worktree is created"))); + this._register(dom.addDisposableListener(runOnText, dom.EventType.CLICK, () => this._runOnCheckbox.checked = !this._runOnCheckbox.checked)); + + const storageSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(storageSection, dom.$('div.run-script-action-label', undefined, localize('storageLabel', "Save In"))); + const storageDisabledReason = state.targetDisabledReason; + const workspaceTargetDisabled = !!storageDisabledReason; + this._storageOptions = this._register(new Radio({ + items: [ + { + text: localize('workspaceStorageLabel', "Workspace"), + tooltip: storageDisabledReason ?? localize('workspaceStorageTooltip', "Save this task in the current workspace"), + isActive: this._selectedTarget === 'workspace', + disabled: workspaceTargetDisabled, + }, + { + text: localize('userStorageLabel', "User"), + tooltip: this._targetLocked ? storageDisabledReason : localize('userStorageTooltip', "Save this task in your user tasks and make it available in all sessions"), + isActive: this._selectedTarget === 'user', + disabled: this._targetLocked, + } + ] + })); + this._storageOptions.domNode.setAttribute('aria-label', localize('storageAriaLabel', "Task storage target")); + storageSection.appendChild(this._storageOptions.domNode); + if (storageDisabledReason && !this._targetLocked) { + dom.append(storageSection, dom.$('div.run-script-action-hint', undefined, storageDisabledReason)); + } + + const buttonRow = dom.append(this.domNode, dom.$('.run-script-action-buttons')); + this._cancelButton = this._register(new Button(buttonRow, { ...defaultButtonStyles, secondary: true })); + this._cancelButton.label = localize('cancelAddAction', "Cancel"); + this._submitButton = this._register(new Button(buttonRow, defaultButtonStyles)); + this._submitButton.label = this._getSubmitLabel(); + + this._register(this._labelInput.onDidChange(() => this._updateButtonState())); + this._register(this._commandInput.onDidChange(() => this._updateButtonState())); + this._register(this._storageOptions.onDidSelect(index => { + this._selectedTarget = index === 0 ? 'workspace' : 'user'; + this._updateButtonState(); + })); + this._register(this._runOnCheckbox.onChange(() => this._updateButtonState())); + this._register(this._submitButton.onDidClick(() => this._submit())); + this._register(this._cancelButton.onDidClick(() => this._onDidCancel.fire())); + this._register(dom.addDisposableListener(this._labelInput.inputElement, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._submit(); + } + })); + this._register(dom.addDisposableListener(this._commandInput.inputElement, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._submit(); + } + })); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Escape)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + this._onDidCancel.fire(); + } + })); + + this._updateButtonState(); + } + + focus(): void { + if (!this._labelLocked) { + this._labelInput.focus(); + return; + } + if (this._commandLocked) { + this._runOnCheckbox.focus(); + return; + } + this._commandInput.focus(); + } + + private _submit(): void { + const label = this._labelInput.value.trim(); + const command = this._commandInput.value.trim(); + if (!command) { + return; + } + + this._onDidSubmit.fire({ + label: label.length > 0 ? label : undefined, + command, + target: this._selectedTarget, + runOn: this._runOnCheckbox.checked ? WORKTREE_CREATED_RUN_ON : undefined, + }); + } + + private _updateButtonState(): void { + this._submitButton.enabled = this._commandInput.value.trim().length > 0; + this._submitButton.label = this._getSubmitLabel(); + } + + private _getSubmitLabel(): string { + if (this._isAddExistingTask) { + return localize('confirmAddToSessions', "Add to Sessions Window"); + } + if (!this._isExistingTask) { + return localize('confirmAddTask', "Add Task"); + } + + const targetChanged = this._selectedTarget !== this._initialTarget; + const labelChanged = this._labelInput.value !== this._initialLabel; + const commandChanged = this._commandInput.value !== this._initialCommand; + const runOnChanged = this._runOnCheckbox.checked !== this._initialRunOn; + const otherChanged = labelChanged || commandChanged || runOnChanged; + + if (targetChanged && otherChanged) { + return localize('confirmMoveAndUpdateTask', "Move and Update Task"); + } + if (targetChanged) { + return localize('confirmMoveTask', "Move Task"); + } + return localize('confirmUpdateTask', "Update Task"); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index 1d762632f9f71..a4a092d83492f 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -2,273 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -import * as dom from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { Radio } from '../../../../base/browser/ui/radio/radio.js'; -import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { localize } from '../../../../nls.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; -import { INewSession } from './newSession.js'; - -/** - * A dropdown menu action item that shows an icon, a text label, and a chevron. - */ -class LabeledDropdownMenuActionViewItem extends DropdownMenuActionViewItem { - protected override renderLabel(element: HTMLElement): null { - const classNames = typeof this.options.classNames === 'string' - ? this.options.classNames.split(/\s+/g).filter(s => !!s) - : (this.options.classNames ?? []); - if (classNames.length > 0) { - const icon = dom.append(element, dom.$('span')); - icon.classList.add('codicon', ...classNames); - } - - const label = dom.append(element, dom.$('span.sessions-chat-dropdown-label')); - label.textContent = this._action.label; - - dom.append(element, renderIcon(Codicon.chevronDown)); - - return null; - } -} - -// #region --- Session Target Picker --- - -/** - * A self-contained widget for selecting the session target (Local vs Cloud). - * Encapsulates state, events, and rendering. Can be placed anywhere in the view. - */ -export class SessionTargetPicker extends Disposable { - - private _selectedTarget: AgentSessionProviders; - private _allowedTargets: AgentSessionProviders[]; - - private readonly _onDidChangeTarget = this._register(new Emitter()); - readonly onDidChangeTarget: Event = this._onDidChangeTarget.event; - - private readonly _renderDisposables = this._register(new DisposableStore()); - private _container: HTMLElement | undefined; - - get selectedTarget(): AgentSessionProviders { - return this._selectedTarget; - } - - constructor( - allowedTargets: AgentSessionProviders[], - defaultTarget: AgentSessionProviders, - ) { - super(); - this._allowedTargets = allowedTargets; - this._selectedTarget = allowedTargets.includes(defaultTarget) - ? defaultTarget - : allowedTargets[0]; - } - - /** - * Renders the target radio (Local / Cloud) into the given container. - */ - render(container: HTMLElement): void { - this._container = container; - this._renderRadio(); - } - - updateAllowedTargets(targets: AgentSessionProviders[]): void { - if (targets.length === 0) { - return; - } - this._allowedTargets = targets; - if (!targets.includes(this._selectedTarget)) { - this._selectedTarget = targets[0]; - this._onDidChangeTarget.fire(this._selectedTarget); - } - if (this._container) { - this._renderRadio(); - } - } - - private _renderRadio(): void { - if (!this._container) { - return; - } - - this._renderDisposables.clear(); - dom.clearNode(this._container); - - if (this._allowedTargets.length === 0) { - return; - } - - const targets = [AgentSessionProviders.Background, AgentSessionProviders.Cloud].filter(t => this._allowedTargets.includes(t)); - const activeIndex = targets.indexOf(this._selectedTarget); - - const radio = new Radio({ - items: targets.map(target => ({ - text: getTargetLabel(target), - isActive: target === this._selectedTarget, - })), - }); - this._renderDisposables.add(radio); - this._container.appendChild(radio.domNode); - - if (activeIndex >= 0) { - radio.setActiveItem(activeIndex); - } - - this._renderDisposables.add(radio.onDidSelect(index => { - const target = targets[index]; - if (this._selectedTarget !== target) { - this._selectedTarget = target; - this._onDidChangeTarget.fire(target); - } - })); - } -} - -function getTargetLabel(provider: AgentSessionProviders): string { - switch (provider) { - case AgentSessionProviders.Local: - case AgentSessionProviders.Background: - return localize('chat.session.providerLabel.local', "Local"); - case AgentSessionProviders.Cloud: - return localize('chat.session.providerLabel.cloud', "Cloud"); - case AgentSessionProviders.Claude: - return 'Claude'; - case AgentSessionProviders.Codex: - return 'Codex'; - case AgentSessionProviders.Growth: - return 'Growth'; - } -} - -// #endregion - -// #region --- Isolation Mode Picker --- - -export type IsolationMode = 'worktree' | 'workspace'; - -/** - * A self-contained widget for selecting the isolation mode (Worktree vs Folder). - * Encapsulates state, events, and rendering. Can be placed anywhere in the view. - */ -export class IsolationModePicker extends Disposable { - - private _isolationMode: IsolationMode = 'worktree'; - private _newSession: INewSession | undefined; - private _repository: IGitRepository | undefined; - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - private readonly _renderDisposables = this._register(new DisposableStore()); - private _container: HTMLElement | undefined; - private _dropdownContainer: HTMLElement | undefined; - - get isolationMode(): IsolationMode { - return this._isolationMode; - } - - constructor( - @IContextMenuService private readonly contextMenuService: IContextMenuService, - ) { - super(); - } - - /** - * Sets the pending session that this picker writes to. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - - /** - * Sets the git repository. When undefined, worktree option is hidden - * and isolation mode falls back to 'workspace'. - */ - setRepository(repository: IGitRepository | undefined): void { - this._repository = repository; - if (repository) { - this._setMode('worktree'); - } else if (this._isolationMode === 'worktree') { - this._setMode('workspace'); - } - this._renderDropdown(); - } - - /** - * Renders the isolation mode dropdown into the given container. - */ - render(container: HTMLElement): void { - this._container = container; - this._dropdownContainer = dom.append(container, dom.$('.sessions-chat-local-mode-left')); - this._renderDropdown(); - } - - /** - * Shows or hides the picker. - */ - setVisible(visible: boolean): void { - if (this._container) { - this._container.style.visibility = visible ? '' : 'hidden'; - } - } - - private _renderDropdown(): void { - if (!this._dropdownContainer) { - return; - } - - this._renderDisposables.clear(); - dom.clearNode(this._dropdownContainer); - - const modeLabel = this._isolationMode === 'worktree' - ? localize('isolationMode.worktree', "Worktree") - : localize('isolationMode.folder', "Folder"); - const modeIcon = this._isolationMode === 'worktree' ? Codicon.worktree : Codicon.folder; - const isDisabled = !this._repository; - - const modeAction = toAction({ id: 'isolationMode', label: modeLabel, run: () => { } }); - const modeDropdown = this._renderDisposables.add(new LabeledDropdownMenuActionViewItem( - modeAction, - { - getActions: () => isDisabled ? [] : [ - toAction({ - id: 'isolationMode.worktree', - label: localize('isolationMode.worktree', "Worktree"), - checked: this._isolationMode === 'worktree', - run: () => this._setMode('worktree'), - }), - toAction({ - id: 'isolationMode.folder', - label: localize('isolationMode.folder', "Folder"), - checked: this._isolationMode === 'workspace', - run: () => this._setMode('workspace'), - }), - ], - }, - this.contextMenuService, - { classNames: [...ThemeIcon.asClassNameArray(modeIcon)] } - )); - const modeSlot = dom.append(this._dropdownContainer, dom.$('.sessions-chat-picker-slot')); - modeDropdown.render(modeSlot); - modeSlot.classList.toggle('disabled', isDisabled); - } - - private _setMode(mode: IsolationMode): void { - if (this._isolationMode !== mode) { - this._isolationMode = mode; - this._newSession?.setIsolationMode(mode); - this._onDidChange.fire(mode); - this._renderDropdown(); - } - } -} - -// #endregion diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts new file mode 100644 index 0000000000000..510ae8feadbb9 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { ISessionType } from '../../sessions/browser/sessionsProvider.js'; + +export class SessionTypePicker extends Disposable { + + private _sessionType: string | undefined; + private _sessionTypes: ISessionType[] = []; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + if (session) { + this._sessionTypes = this.sessionsProvidersService.getSessionTypes(session); + this._sessionType = session.sessionType; + } else { + this._sessionTypes = []; + this._sessionType = undefined; + } + this._updateTriggerLabel(); + })); + } + + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + if (this._sessionTypes.length <= 1) { + return; + } + + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return; + } + + const items: IActionListItem[] = this._sessionTypes.map(type => ({ + kind: ActionListItemKind.Action, + label: type.label, + group: { title: '', icon: type.icon }, + item: type.id === this._sessionType ? { ...type, checked: true } : type, + })); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (type) => { + this.actionWidgetService.hide(); + this.sessionsManagementService.setSessionType(session, type); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'sessionTypePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('sessionTypePicker.ariaLabel', "Session Type"), + }, + ); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + const currentType = this._sessionTypes.find(t => t.id === this._sessionType); + const modeIcon = currentType?.icon ?? Codicon.terminal; + const modeLabel = currentType?.label ?? this._sessionType ?? ''; + + dom.append(this._triggerElement, renderIcon(modeIcon)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = modeLabel; + + const hasMultipleTypes = this._sessionTypes.length > 1; + this._slotElement?.classList.toggle('disabled', !hasMultipleTypes); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts new file mode 100644 index 0000000000000..b279cc8b2546c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -0,0 +1,484 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ISessionWorkspace } from '../../sessions/common/sessionData.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsBrowseAction } from '../../sessions/browser/sessionsProvider.js'; +import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; + +const LEGACY_STORAGE_KEY_RECENT_PROJECTS = 'sessions.recentlyPickedProjects'; +const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; +const FILTER_THRESHOLD = 10; +const MAX_RECENT_WORKSPACES = 10; + +/** + * A workspace selection from the picker, pairing the workspace with its owning provider. + */ +export interface IWorkspaceSelection { + readonly providerId: string; + readonly workspace: ISessionWorkspace; +} + +/** + * Stored recent workspace entry. The `checked` flag marks the currently + * selected workspace so we only need a single storage key. + */ +interface IStoredRecentWorkspace { + readonly uri: UriComponents; + readonly providerId: string; + readonly checked: boolean; +} + +/** + * Item type used in the action list. + */ +interface IWorkspacePickerItem { + readonly selection?: IWorkspaceSelection; + readonly browseActionIndex?: number; + readonly checked?: boolean; +} + +/** + * A unified workspace picker that shows workspaces from all registered session + * providers in a single dropdown. + * + * Browse actions from providers are appended at the bottom of the list. + */ +export class WorkspacePicker extends Disposable { + + private readonly _onDidSelectWorkspace = this._register(new Emitter()); + readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + + private _selectedWorkspace: IWorkspaceSelection | undefined; + + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + get selectedProject(): IWorkspaceSelection | undefined { + return this._selectedWorkspace; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IStorageService private readonly storageService: IStorageService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + // Migrate legacy storage to new key + this._migrateLegacyStorage(); + + // Restore selected workspace from storage + this._selectedWorkspace = this._restoreSelectedWorkspace(); + + // If restore failed (providers not yet registered), retry when providers appear + if (!this._selectedWorkspace && this._hasStoredWorkspace()) { + const providerListener = this._register(this.sessionsProvidersService.onDidChangeProviders(() => { + if (!this._selectedWorkspace) { + const restored = this._restoreSelectedWorkspace(); + if (restored) { + this._selectedWorkspace = restored; + this._updateTriggerLabel(); + this._onDidSelectWorkspace.fire(restored); + } + } + if (this._selectedWorkspace) { + providerListener.dispose(); + } + })); + } + } + + /** + * Renders the project picker trigger button into the given container. + * Returns the container element. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + /** + * Shows the workspace picker dropdown anchored to the trigger element. + */ + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.browseActionIndex !== undefined) { + this._executeBrowseAction(item.browseActionIndex); + } else if (item.selection) { + this._selectProject(item.selection); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces...") } : undefined; + + this.actionWidgetService.show( + 'workspacePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"), + }, + listOptions, + ); + } + + /** + * Programmatically set the selected project. + * @param fireEvent Whether to fire the onDidSelectWorkspace event. Defaults to true. + */ + setSelectedProject(project: IWorkspaceSelection, fireEvent = true): void { + this._selectProject(project, fireEvent); + } + + /** + * Clears the selected project. + */ + clearSelection(): void { + this._selectedWorkspace = undefined; + // Clear checked state from all recents + const recents = this._getStoredRecentWorkspaces(); + const updated = recents.map(p => ({ ...p, checked: false })); + this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); + this._updateTriggerLabel(); + } + + /** + * Clears the selection if it matches the given URI. + */ + removeFromRecents(uri: URI): void { + if (this._selectedWorkspace && this.uriIdentityService.extUri.isEqual(this._selectedWorkspace.workspace.repositories[0]?.uri, uri)) { + this.clearSelection(); + } + } + + private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void { + this._selectedWorkspace = selection; + this._persistSelectedWorkspace(selection); + this._updateTriggerLabel(); + if (fireEvent) { + this._onDidSelectWorkspace.fire(selection); + } + } + + /** + * Executes a browse action from a provider, identified by index. + */ + private async _executeBrowseAction(actionIndex: number): Promise { + const allActions = this._getAllBrowseActions(); + const action = allActions[actionIndex]; + if (!action) { + return; + } + + try { + const workspace = await action.execute(); + if (workspace) { + this._selectProject({ providerId: action.providerId, workspace }); + } + } catch { + // browse action was cancelled or failed + } + } + + private _getActiveProviders(): import('../../sessions/browser/sessionsProvider.js').ISessionsProvider[] { + const activeProviderId = this.sessionsManagementService.activeProviderId.get(); + const allProviders = this.sessionsProvidersService.getProviders(); + if (activeProviderId) { + const active = allProviders.find(p => p.id === activeProviderId); + if (active) { + return [active]; + } + } + return allProviders; + } + + /** + * Collects browse actions from all registered providers. + */ + private _getAllBrowseActions(): ISessionsBrowseAction[] { + return this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); + } + + private _buildItems(): IActionListItem[] { + const items: IActionListItem[] = []; + + // Collect recent workspaces from picker storage across all providers + const allProviders = this.sessionsProvidersService.getProviders(); + const providerIds = new Set(allProviders.map(p => p.id)); + const recentWorkspaces = this._getRecentWorkspaces().filter(w => providerIds.has(w.providerId)); + const hasMultipleProviders = allProviders.length > 1; + + if (hasMultipleProviders) { + // Group workspaces by provider + for (const provider of allProviders) { + const providerWorkspaces = recentWorkspaces.filter(w => w.providerId === provider.id); + if (providerWorkspaces.length === 0) { + continue; + } + items.push({ + kind: ActionListItemKind.Header, + label: provider.label, + group: { title: provider.label, icon: provider.icon }, + item: {}, + }); + for (const { workspace, providerId } of providerWorkspaces) { + const selection: IWorkspaceSelection = { providerId, workspace }; + const selected = this._isSelectedWorkspace(selection); + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + group: { title: '', icon: workspace.icon }, + item: { selection, checked: selected || undefined }, + }); + } + } + } else { + for (const { workspace, providerId } of recentWorkspaces) { + const selection: IWorkspaceSelection = { providerId, workspace }; + const selected = this._isSelectedWorkspace(selection); + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + group: { title: '', icon: workspace.icon }, + item: { selection, checked: selected || undefined }, + }); + } + } + + // Browse actions from all providers + const allBrowseActions = this._getAllBrowseActions(); + if (items.length > 0 && allBrowseActions.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + for (let i = 0; i < allBrowseActions.length; i++) { + const action = allBrowseActions[i]; + items.push({ + kind: ActionListItemKind.Action, + label: action.label, + group: { title: '', icon: action.icon }, + item: { browseActionIndex: i }, + }); + } + + return items; + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const workspace = this._selectedWorkspace?.workspace; + const label = workspace ? workspace.label : localize('pickWorkspace', "Pick a Workspace"); + const icon = workspace ? workspace.icon : Codicon.project; + + dom.append(this._triggerElement, renderIcon(icon)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + } + + private _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { + if (!this._selectedWorkspace) { + return false; + } + return this._selectedWorkspace.providerId === selection.providerId + && this._selectedWorkspace.workspace.label === selection.workspace.label; + } + + private _persistSelectedWorkspace(selection: IWorkspaceSelection): void { + const uri = selection.workspace.repositories[0]?.uri; + if (!uri) { + return; + } + this._addRecentWorkspace(selection.providerId, selection.workspace, true); + } + + private _hasStoredWorkspace(): boolean { + return this._getStoredRecentWorkspaces().length > 0; + } + + private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined { + try { + const providers = this._getActiveProviders(); + const providerIds = new Set(providers.map(p => p.id)); + const storedRecents = this._getStoredRecentWorkspaces(); + + // Find the checked entry for an active provider + for (const stored of storedRecents) { + if (!stored.checked || !providerIds.has(stored.providerId)) { + continue; + } + const uri = URI.revive(stored.uri); + const workspace = this.sessionsProvidersService.resolveWorkspace(stored.providerId, uri); + if (workspace) { + return { providerId: stored.providerId, workspace }; + } + } + + // No checked entry found — fall back to the first resolvable recent workspace + for (const stored of storedRecents) { + if (!providerIds.has(stored.providerId)) { + continue; + } + const uri = URI.revive(stored.uri); + const workspace = this.sessionsProvidersService.resolveWorkspace(stored.providerId, uri); + if (workspace) { + return { providerId: stored.providerId, workspace }; + } + } + return undefined; + } catch { + return undefined; + } + } + + /** + * Migrate legacy `sessions.recentlyPickedProjects` storage to the new + * `sessions.recentlyPickedWorkspaces` key, adding `providerId` (defaulting + * to Copilot) and ensuring at least one entry is checked. + */ + private _migrateLegacyStorage(): void { + // Already migrated + if (this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE)) { + return; + } + + const raw = this.storageService.get(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE); + if (!raw) { + return; + } + + try { + const parsed = JSON.parse(raw) as { uri: UriComponents; checked?: boolean }[]; + const hasAnyChecked = parsed.some(e => e.checked); + const migrated: IStoredRecentWorkspace[] = parsed.map((entry, index) => ({ + uri: entry.uri, + providerId: COPILOT_PROVIDER_ID, + checked: hasAnyChecked ? !!entry.checked : index === 0, + })); + this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(migrated), StorageScope.PROFILE, StorageTarget.MACHINE); + } catch { /* ignore */ } + + this.storageService.remove(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE); + } + + // -- Recent workspaces storage -- + + private _addRecentWorkspace(providerId: string, workspace: ISessionWorkspace, checked: boolean): void { + const uri = workspace.repositories[0]?.uri; + if (!uri) { + return; + } + const recents = this._getStoredRecentWorkspaces(); + const filtered = recents.map(p => { + // Remove the entry being re-added (it will go to the front) + if (p.providerId === providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) { + return undefined; + } + // Clear checked from other entries for the same provider when marking checked + if (checked && p.providerId === providerId) { + return { ...p, checked: false }; + } + return p; + }).filter((p): p is IStoredRecentWorkspace => p !== undefined); + + const entry: IStoredRecentWorkspace = { uri: uri.toJSON(), providerId, checked }; + const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES); + this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + private _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] { + return this._getStoredRecentWorkspaces() + .map(stored => { + const uri = URI.revive(stored.uri); + const workspace = this.sessionsProvidersService.resolveWorkspace(stored.providerId, uri); + if (!workspace) { + return undefined; + } + return { providerId: stored.providerId, workspace }; + }) + .filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined) + .sort((a, b) => { + // Local folders first, then remote repositories, alphabetical within each group + const aIsLocal = a.workspace.repositories[0]?.uri.scheme === Schemas.file; + const bIsLocal = b.workspace.repositories[0]?.uri.scheme === Schemas.file; + if (aIsLocal !== bIsLocal) { + return aIsLocal ? -1 : 1; + } + return a.workspace.label.localeCompare(b.workspace.label); + }); + } + + private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] { + const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + if (!raw) { + return []; + } + try { + return JSON.parse(raw) as IStoredRecentWorkspace[]; + } catch { + return []; + } + } + +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 4183a311f60a2..39d52cfe857a5 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -7,19 +7,25 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; -import { isMacintosh, isWindows } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionData } from '../../sessions/common/sessionData.js'; import { IJSONEditingService } from '../../../../workbench/services/configuration/common/jsonEditing.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; -import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.js'; +import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js'; +import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js'; export type TaskStorageTarget = 'user' | 'workspace'; +type TaskRunOnOption = 'default' | 'folderOpen' | 'worktreeCreated'; + +interface ITaskRunOptions { + readonly runOn?: TaskRunOnOption; +} /** * Shape of a single task entry inside tasks.json. @@ -30,13 +36,28 @@ export interface ITaskEntry { readonly script?: string; readonly type?: string; readonly command?: string; + readonly args?: CommandString[]; readonly inSessions?: boolean; - readonly windows?: { command?: string }; - readonly osx?: { command?: string }; - readonly linux?: { command?: string }; + readonly runOptions?: ITaskRunOptions; + readonly windows?: { command?: string; args?: CommandString[] }; + readonly osx?: { command?: string; args?: CommandString[] }; + readonly linux?: { command?: string; args?: CommandString[] }; readonly [key: string]: unknown; } +export interface INonSessionTaskEntry { + readonly task: ITaskEntry; + readonly target: TaskStorageTarget; +} + +/** + * A session task together with the storage target it was loaded from. + */ +export interface ISessionTaskWithTarget { + readonly task: ITaskEntry; + readonly target: TaskStorageTarget; +} + interface ITasksJson { version?: string; tasks?: ITaskEntry[]; @@ -47,38 +68,55 @@ export interface ISessionsConfigurationService { /** * Observable list of tasks with `inSessions: true`, automatically - * updated when the tasks.json file changes. + * updated when the tasks.json file changes. Each entry includes the + * storage target the task was loaded from. */ - getSessionTasks(session: IActiveSessionItem): IObservable; + getSessionTasks(session: ISessionData): IObservable; /** * Returns tasks that do NOT have `inSessions: true` — used as * suggestions in the "Add Run Action" picker. */ - getNonSessionTasks(session: IActiveSessionItem): Promise; + getNonSessionTasks(session: ISessionData): Promise; /** * Sets `inSessions: true` on an existing task (identified by label), * updating it in place in its tasks.json. */ - addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget): Promise; + addTaskToSessions(task: ITaskEntry, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Creates a new shell task with `inSessions: true` and writes it to * the appropriate tasks.json (user or workspace). */ - createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise; + createAndAddTask(label: string | undefined, command: string, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** - * Runs a task entry in a terminal, resolving the correct platform - * command and using the session worktree as cwd. + * Updates an existing task entry, optionally moving it between user and + * workspace storage. */ - runTask(task: ITaskEntry, session: IActiveSessionItem): Promise; + updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISessionData, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise; /** - * Observable label of the most recently run task for the given repository. + * Removes an existing task entry from its tasks.json. */ - getLastRunTaskLabel(repository: URI | undefined): IObservable; + removeTask(taskLabel: string, session: ISessionData, target: TaskStorageTarget): Promise; + + /** + * Runs a task via the task service, looking it up by label in the + * workspace folder corresponding to the session worktree. + */ + runTask(task: ITaskEntry, session: ISessionData): Promise; + + /** + * Observable label of the pinned task for the given repository. + */ + getPinnedTaskLabel(repository: URI | undefined): IObservable; + + /** + * Sets or clears the pinned task for the given repository. + */ + setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void; } export const ISessionsConfigurationService = createDecorator('sessionsConfigurationService'); @@ -87,14 +125,11 @@ export class SessionsConfigurationService extends Disposable implements ISession declare readonly _serviceBrand: undefined; - private static readonly _LAST_RUN_TASK_LABELS_KEY = 'agentSessions.lastRunTaskLabels'; - - private readonly _sessionTasks = observableValue(this, []); + private static readonly _PINNED_TASK_LABELS_KEY = 'agentSessions.pinnedTaskLabels'; + private readonly _sessionTasks = observableValue(this, []); private readonly _fileWatcher = this._register(new MutableDisposable()); - /** Maps `cwd.toString() + command` to the terminal `instanceId`. */ - private readonly _taskTerminals = new Map(); - private readonly _lastRunTaskLabels: Map; - private readonly _lastRunTaskObservables = new Map>>(); + private readonly _pinnedTaskLabels: Map; + private readonly _pinnedTaskObservables = new Map>>(); private _watchedResource: URI | undefined; private _lastRefreshedFolder: URI | undefined; @@ -103,16 +138,18 @@ export class SessionsConfigurationService extends Disposable implements ISession @IFileService private readonly _fileService: IFileService, @IJSONEditingService private readonly _jsonEditingService: IJSONEditingService, @IPreferencesService private readonly _preferencesService: IPreferencesService, - @ITerminalService private readonly _terminalService: ITerminalService, + @ITaskService private readonly _taskService: ITaskService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @IStorageService private readonly _storageService: IStorageService, ) { super(); - this._lastRunTaskLabels = this._loadLastRunTaskLabels(); + this._pinnedTaskLabels = this._loadPinnedTaskLabels(); } - getSessionTasks(session: IActiveSessionItem): IObservable { - const folder = session.worktree ?? session.repository; + getSessionTasks(session: ISessionData): IObservable { + const repo = this._getSessionRepo(session); + const folder = repo?.workingDirectory ?? repo?.uri; if (folder) { this._ensureFileWatch(folder); } @@ -124,12 +161,33 @@ export class SessionsConfigurationService extends Disposable implements ISession return this._sessionTasks; } - async getNonSessionTasks(session: IActiveSessionItem): Promise { - const allTasks = await this._readAllTasks(session); - return allTasks.filter(t => !t.inSessions); + async getNonSessionTasks(session: ISessionData): Promise { + const result: INonSessionTaskEntry[] = []; + + const workspaceUri = this._getTasksJsonUri(session, 'workspace'); + if (workspaceUri) { + const workspaceJson = await this._readTasksJson(workspaceUri); + for (const task of workspaceJson.tasks ?? []) { + if (!task.inSessions && this._isSupportedTask(task)) { + result.push({ task, target: 'workspace' }); + } + } + } + + const userUri = this._getTasksJsonUri(session, 'user'); + if (userUri) { + const userJson = await this._readTasksJson(userUri); + for (const task of userJson.tasks ?? []) { + if (!task.inSessions && this._isSupportedTask(task)) { + result.push({ task, target: 'user' }); + } + } + } + + return result; } - async addTaskToSessions(task: ITaskEntry, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async addTaskToSessions(task: ITaskEntry, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -142,28 +200,39 @@ export class SessionsConfigurationService extends Disposable implements ISession return; } - await this._jsonEditingService.write(tasksJsonUri, [ - { path: ['tasks', index, 'inSessions'], value: true } - ], true); + const edits: { path: (string | number)[]; value: unknown }[] = [ + { path: ['tasks', index, 'inSessions'], value: true }, + ]; + + if (options) { + edits.push({ + path: ['tasks', index, 'runOptions'], + value: options.runOn && options.runOn !== 'default' ? { runOn: options.runOn } : undefined, + }); + } + + await this._jsonEditingService.write(tasksJsonUri, edits, true); if (target === 'workspace') { await this._commitTasksFile(session); } } - async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async createAndAddTask(label: string | undefined, command: string, session: ISessionData, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { - return; + return undefined; } const tasksJson = await this._readTasksJson(tasksJsonUri); const tasks = tasksJson.tasks ?? []; + const resolvedLabel = label?.trim() || command; const newTask: ITaskEntry = { - label: command, + label: resolvedLabel, type: 'shell', command, inSessions: true, + ...(options?.runOn && options.runOn !== 'default' ? { runOptions: { runOn: options.runOn } } : {}), }; await this._jsonEditingService.write(tasksJsonUri, [ @@ -174,75 +243,137 @@ export class SessionsConfigurationService extends Disposable implements ISession if (target === 'workspace') { await this._commitTasksFile(session); } + + return newTask; } - async runTask(task: ITaskEntry, session: IActiveSessionItem): Promise { - const command = this._resolveCommand(task); - if (!command) { + async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISessionData, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise { + const currentTasksJsonUri = this._getTasksJsonUri(session, currentTarget); + const newTasksJsonUri = this._getTasksJsonUri(session, newTarget); + if (!currentTasksJsonUri || !newTasksJsonUri) { return; } - const cwd = session.worktree ?? session.repository; - if (!cwd) { + const currentTasksJson = await this._readTasksJson(currentTasksJsonUri); + const currentTasks = currentTasksJson.tasks ?? []; + const currentIndex = currentTasks.findIndex(task => task.label === originalTaskLabel); + if (currentIndex === -1) { return; } - const terminalKey = `${cwd.toString()}${command}`; - let terminal = this._getExistingTerminalInstance(terminalKey); - if (!terminal) { - terminal = await this._terminalService.createTerminal({ - location: TerminalLocation.Panel, - config: { name: task.label }, - cwd - }); - this._taskTerminals.set(terminalKey, terminal.instanceId); - } - await terminal.sendText(command, true); - this._terminalService.setActiveInstance(terminal); - await this._terminalService.revealActiveTerminal(); - - if (session.repository) { - const key = session.repository.toString(); - this._lastRunTaskLabels.set(key, task.label); - this._saveLastRunTaskLabels(); - const obs = this._lastRunTaskObservables.get(key); - if (obs) { - transaction(tx => obs.set(task.label, tx)); + if (currentTasksJsonUri.toString() === newTasksJsonUri.toString()) { + await this._jsonEditingService.write(currentTasksJsonUri, [ + { path: ['tasks', currentIndex], value: updatedTask }, + ], true); + } else { + const newTasksJson = await this._readTasksJson(newTasksJsonUri); + const newTasks = newTasksJson.tasks ?? []; + + await this._jsonEditingService.write(currentTasksJsonUri, [ + { path: ['tasks'], value: currentTasks.filter((_, taskIndex) => taskIndex !== currentIndex) }, + ], true); + + await this._jsonEditingService.write(newTasksJsonUri, [ + { path: ['version'], value: newTasksJson.version ?? '2.0.0' }, + { path: ['tasks'], value: [...newTasks, updatedTask] }, + ], true); + } + + if (currentTarget === 'workspace' || newTarget === 'workspace') { + await this._commitTasksFile(session); + } + + const repoUri = this._getSessionRepo(session)?.uri; + if (repoUri) { + const key = repoUri.toString(); + if (this._pinnedTaskLabels.get(key) === originalTaskLabel) { + this._setPinnedTaskLabelForKey(key, updatedTask.label); } } } - getLastRunTaskLabel(repository: URI | undefined): IObservable { + async removeTask(taskLabel: string, session: ISessionData, target: TaskStorageTarget): Promise { + const tasksJsonUri = this._getTasksJsonUri(session, target); + if (!tasksJsonUri) { + return; + } + + const tasksJson = await this._readTasksJson(tasksJsonUri); + const tasks = tasksJson.tasks ?? []; + const index = tasks.findIndex(t => t.label === taskLabel); + if (index === -1) { + return; + } + + await this._jsonEditingService.write(tasksJsonUri, [ + { path: ['tasks'], value: tasks.filter((_, taskIndex) => taskIndex !== index) }, + ], true); + + if (target === 'workspace') { + await this._commitTasksFile(session); + } + + const repoUri = this._getSessionRepo(session)?.uri; + if (repoUri) { + const key = repoUri.toString(); + if (this._pinnedTaskLabels.get(key) === taskLabel) { + this._setPinnedTaskLabelForKey(key, undefined); + } + } + } + + async runTask(task: ITaskEntry, session: ISessionData): Promise { + const repo = this._getSessionRepo(session); + const cwd = repo?.workingDirectory ?? repo?.uri; + if (!cwd) { + return; + } + + const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd); + if (!workspaceFolder) { + return; + } + + const resolvedTask = await this._taskService.getTask(workspaceFolder, task.label); + if (!resolvedTask) { + return; + } + + await this._taskService.run(resolvedTask, undefined, TaskRunSource.User); + } + + getPinnedTaskLabel(repository: URI | undefined): IObservable { if (!repository) { - return observableValue('lastRunTaskLabel', undefined); + return observableValue('pinnedTaskLabel', undefined); } + const key = repository.toString(); - let obs = this._lastRunTaskObservables.get(key); + let obs = this._pinnedTaskObservables.get(key); if (!obs) { - obs = observableValue('lastRunTaskLabel', this._lastRunTaskLabels.get(key)); - this._lastRunTaskObservables.set(key, obs); + obs = observableValue('pinnedTaskLabel', this._pinnedTaskLabels.get(key)); + this._pinnedTaskObservables.set(key, obs); } return obs; } + setPinnedTaskLabel(repository: URI | undefined, taskLabel: string | undefined): void { + if (!repository) { + return; + } + + this._setPinnedTaskLabelForKey(repository.toString(), taskLabel); + } + // --- private helpers --- - private _getExistingTerminalInstance(terminalKey: string): ITerminalInstance | undefined { - const instanceId = this._taskTerminals.get(terminalKey); - if (instanceId === undefined) { - return undefined; - } - const instance = this._terminalService.instances.find(i => i.instanceId === instanceId); - if (!instance || instance.hasChildProcesses) { - this._taskTerminals.delete(terminalKey); - return undefined; - } - return instance; + private _getSessionRepo(session: ISessionData) { + return session.workspace.get()?.repositories[0]; } - private _getTasksJsonUri(session: IActiveSessionItem, target: TaskStorageTarget): URI | undefined { + private _getTasksJsonUri(session: ISessionData, target: TaskStorageTarget): URI | undefined { if (target === 'workspace') { - const folder = session.worktree ?? session.repository; + const repo = this._getSessionRepo(session); + const folder = repo?.workingDirectory ?? repo?.uri; return folder ? joinPath(folder, '.vscode', 'tasks.json') : undefined; } return joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); @@ -257,41 +388,8 @@ export class SessionsConfigurationService extends Disposable implements ISession } } - private async _readAllTasks(session: IActiveSessionItem): Promise { - const result: ITaskEntry[] = []; - - // Read workspace tasks - const workspaceUri = this._getTasksJsonUri(session, 'workspace'); - if (workspaceUri) { - const workspaceJson = await this._readTasksJson(workspaceUri); - if (workspaceJson.tasks) { - result.push(...workspaceJson.tasks); - } - } - - // Read user tasks - const userUri = this._getTasksJsonUri(session, 'user'); - if (userUri) { - const userJson = await this._readTasksJson(userUri); - if (userJson.tasks) { - result.push(...userJson.tasks); - } - } - - return result; - } - - private _resolveCommand(task: ITaskEntry): string | undefined { - if (isWindows && task.windows?.command) { - return task.windows.command; - } - if (isMacintosh && task.osx?.command) { - return task.osx.command; - } - if (!isWindows && !isMacintosh && task.linux?.command) { - return task.linux.command; - } - return task.command; + private _isSupportedTask(task: ITaskEntry): boolean { + return !!task.label; } private _ensureFileWatch(folder: URI): void { @@ -303,9 +401,15 @@ export class SessionsConfigurationService extends Disposable implements ISession const disposables = new DisposableStore(); + // Watch workspace tasks.json disposables.add(this._fileService.watch(tasksUri)); + + // Also watch user-level tasks.json so that user session tasks changes refresh the observable + const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); + disposables.add(this._fileService.watch(userUri)); + disposables.add(this._fileService.onDidFilesChange(e => { - if (e.affects(tasksUri)) { + if (e.affects(tasksUri) || e.affects(userUri)) { this._refreshSessionTasks(folder); } })); @@ -321,18 +425,22 @@ export class SessionsConfigurationService extends Disposable implements ISession const tasksUri = joinPath(folder, '.vscode', 'tasks.json'); const tasksJson = await this._readTasksJson(tasksUri); - const sessionTasks = (tasksJson.tasks ?? []).filter(t => t.inSessions); + const sessionTasks: ISessionTaskWithTarget[] = (tasksJson.tasks ?? []) + .filter(t => t.inSessions && this._isSupportedTask(t)) + .map(t => ({ task: t, target: 'workspace' as TaskStorageTarget })); // Also include user-level session tasks const userUri = joinPath(dirname(this._preferencesService.userSettingsResource), 'tasks.json'); const userJson = await this._readTasksJson(userUri); - const userSessionTasks = (userJson.tasks ?? []).filter(t => t.inSessions); + const userSessionTasks: ISessionTaskWithTarget[] = (userJson.tasks ?? []) + .filter(t => t.inSessions && this._isSupportedTask(t)) + .map(t => ({ task: t, target: 'user' as TaskStorageTarget })); transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx)); } - private async _commitTasksFile(session: IActiveSessionItem): Promise { - const worktree = session.worktree; // Only commit if there's a worktree. The local scenario does not need it + private async _commitTasksFile(session: ISessionData): Promise { + const worktree = this._getSessionRepo(session)?.workingDirectory; // Only commit if there's a worktree. The local scenario does not need it if (!worktree) { return; } @@ -340,8 +448,8 @@ export class SessionsConfigurationService extends Disposable implements ISession await this._sessionsManagementService.commitWorktreeFiles(session, [tasksUri]); } - private _loadLastRunTaskLabels(): Map { - const raw = this._storageService.get(SessionsConfigurationService._LAST_RUN_TASK_LABELS_KEY, StorageScope.APPLICATION); + private _loadPinnedTaskLabels(): Map { + const raw = this._storageService.get(SessionsConfigurationService._PINNED_TASK_LABELS_KEY, StorageScope.APPLICATION); if (raw) { try { return new Map(Object.entries(JSON.parse(raw))); @@ -352,12 +460,27 @@ export class SessionsConfigurationService extends Disposable implements ISession return new Map(); } - private _saveLastRunTaskLabels(): void { + private _savePinnedTaskLabels(): void { this._storageService.store( - SessionsConfigurationService._LAST_RUN_TASK_LABELS_KEY, - JSON.stringify(Object.fromEntries(this._lastRunTaskLabels)), + SessionsConfigurationService._PINNED_TASK_LABELS_KEY, + JSON.stringify(Object.fromEntries(this._pinnedTaskLabels)), StorageScope.APPLICATION, StorageTarget.USER ); } + + private _setPinnedTaskLabelForKey(key: string, taskLabel: string | undefined): void { + if (taskLabel === undefined) { + this._pinnedTaskLabels.delete(key); + } else { + this._pinnedTaskLabels.set(key, taskLabel); + } + + this._savePinnedTaskLabels(); + + const obs = this._pinnedTaskObservables.get(key); + if (obs) { + transaction(tx => obs.set(taskLabel, tx)); + } + } } diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts new file mode 100644 index 0000000000000..4cf481f915e2f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../base/common/themables.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js'; +import { localize } from '../../../../nls.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; + +/** + * Static command ID used by completion items to trigger immediate slash command execution, + * mirroring the pattern of core's `ChatSubmitAction` for `executeImmediately` commands. + */ +export const SESSIONS_EXECUTE_SLASH_COMMAND_ID = 'sessions.chat.executeSlashCommand'; + +CommandsRegistry.registerCommand(SESSIONS_EXECUTE_SLASH_COMMAND_ID, (_, handler: SlashCommandHandler, slashCommandStr: string) => { + handler.tryExecuteSlashCommand(slashCommandStr); + handler.clearInput(); +}); + +/** + * Minimal slash command descriptor for the sessions new-chat widget. + * Self-contained copy of the essential fields from core's `IChatSlashData` + * to avoid a direct dependency on the workbench chat slash command service. + */ +interface ISessionsSlashCommandData { + readonly command: string; + readonly detail: string; + readonly sortText?: string; + readonly executeImmediately?: boolean; + readonly execute: (args: string) => void; +} + +/** + * Manages slash commands for the sessions new-chat input widget — registration, + * autocompletion, decorations (syntax highlighting + placeholder text), and execution. + */ +export class SlashCommandHandler extends Disposable { + + private static readonly _slashDecoType = 'sessions-slash-command'; + private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; + private static _slashDecosRegistered = false; + + private readonly _slashCommands: ISessionsSlashCommandData[] = []; + private _cachedPromptCommands: readonly IChatPromptSlashCommand[] = []; + + constructor( + private readonly _editor: CodeEditorWidget, + @ICommandService private readonly commandService: ICommandService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IThemeService private readonly themeService: IThemeService, + @IAICustomizationWorkspaceService private readonly aiCustomizationWorkspaceService: IAICustomizationWorkspaceService, + @IPromptsService private readonly promptsService: IPromptsService, + ) { + super(); + this._registerSlashCommands(); + this._registerCompletions(); + this._registerDecorations(); + this._refreshPromptCommands(); + this._register(this.promptsService.onDidChangeSlashCommands(() => this._refreshPromptCommands())); + } + + clearInput(): void { + this._editor.getModel()?.setValue(''); + } + + private _refreshPromptCommands(): void { + this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(CancellationToken.None).then(commands => { + this._cachedPromptCommands = commands; + this._updateDecorations(); + }, () => { /* swallow errors from stale refresh */ }); + } + + /** + * Attempts to parse and execute a slash command from the input. + * Returns `true` if a command was handled. + */ + tryExecuteSlashCommand(query: string): boolean { + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); + if (!match) { + return false; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + return false; + } + + slashCommand.execute(match[2]?.trim() ?? ''); + return true; + } + + /** + * If the query starts with a prompt/skill slash command (e.g. `/my-prompt args`), + * expands it into a CLI-friendly markdown reference so the agent can locate the + * file. Returns `undefined` when the query is not a prompt slash command. + */ + tryExpandPromptSlashCommand(query: string): string | undefined { + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); + if (!match) { + return undefined; + } + + const commandName = match[1]; + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!promptCommand) { + return undefined; + } + + const args = match[2]?.trim() ?? ''; + const uri = promptCommand.promptPath.uri; + const typeLabel = promptCommand.promptPath.type === PromptsType.skill ? 'skill' : 'prompt file'; + const expanded = `Use the ${typeLabel} located at [${promptCommand.name}](${uri.toString()}).`; + return args ? `${expanded} ${args}` : expanded; + } + + private _registerSlashCommands(): void { + const openSection = (section: AICustomizationManagementSection) => + () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); + + this._slashCommands.push({ + command: 'agents', + detail: localize('slashCommand.agents', "View and manage custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Agents), + }); + this._slashCommands.push({ + command: 'skills', + detail: localize('slashCommand.skills', "View and manage skills"), + sortText: 'z3_skills', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Skills), + }); + this._slashCommands.push({ + command: 'instructions', + detail: localize('slashCommand.instructions', "View and manage instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Instructions), + }); + this._slashCommands.push({ + command: 'prompts', + detail: localize('slashCommand.prompts', "View and manage prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Prompts), + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "View and manage hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Hooks), + }); + } + + private _registerDecorations(): void { + if (!SlashCommandHandler._slashDecosRegistered) { + SlashCommandHandler._slashDecosRegistered = true; + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashDecoType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + }); + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, {}); + } + + this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations())); + this._updateDecorations(); + } + + private _updateDecorations(): void { + const model = this._editor.getModel(); + const value = model?.getValue() ?? ''; + const match = value.match(/^\/([\w\p{L}\d_\-\.:]+)\s?/u); + + if (!match) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!slashCommand && !promptCommand) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + // Highlight the slash command text + const commandEnd = match[0].trimEnd().length; + const commandDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 }, + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, commandDeco); + + // Show the command description as a placeholder after the command + const restOfInput = value.slice(match[0].length).trim(); + const detail = slashCommand?.detail ?? promptCommand?.description; + if (!restOfInput && detail) { + const placeholderCol = match[0].length + 1; + const placeholderDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, + renderOptions: { + after: { + contentText: detail, + color: this._getPlaceholderColor(), + } + } + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, placeholderDeco); + } else { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + } + } + + private _getPlaceholderColor(): string | undefined { + const theme = this.themeService.getColorTheme(); + return theme.getColor(inputPlaceholderForeground)?.toString(); + } + + private _registerCompletions(): void { + const uri = this._editor.getModel()?.uri; + if (!uri) { + return; + } + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + // Only allow slash commands at the start of input + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + return { + suggestions: this._slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: c.executeImmediately ? '' : `${withSlash} `, + detail: c.detail, + range, + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, + command: c.executeImmediately ? { id: SESSIONS_EXECUTE_SLASH_COMMAND_ID, title: withSlash, arguments: [this, withSlash] } : undefined, + }; + }) + }; + } + })); + + // Dynamic completions for individual prompt/skill files (filtered to match + // what the sessions customizations view shows). + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsPromptSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/[\p{L}0-9_.:-]*/gu); + if (!range) { + return null; + } + + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + const promptCommands = await this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(token); + const userInvocable = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + if (userInvocable.length === 0) { + return null; + } + + return { + suggestions: userInvocable.map((c, i): CompletionItem => { + const label = `/${c.name}`; + return { + label: { label, description: c.description }, + insertText: `${label} `, + documentation: c.description, + range, + sortText: 'b'.repeat(i + 1), + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); + } + + private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + return; + } + + if (!varWord && position.column > 1) { + const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); + if (textBefore !== ' ') { + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace }; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/syncIndicator.ts b/src/vs/sessions/contrib/chat/browser/syncIndicator.ts new file mode 100644 index 0000000000000..b63411982720a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/syncIndicator.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; + +const GIT_SYNC_COMMAND = 'git.sync'; + +/** + * Renders a compact "Synchronize Changes" button next to the branch picker. + * Shows ahead/behind counts (e.g. "3↓ 2↑") and is only visible when + * the selected branch matches the repository HEAD and has changes to sync. + */ +export class SyncIndicator extends Disposable { + + private _repository: IGitRepository | undefined; + private _selectedBranch: string | undefined; + private _visible = true; + private _syncing = false; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _stateDisposables = this._register(new DisposableStore()); + + private _slotElement: HTMLElement | undefined; + private _buttonElement: HTMLElement | undefined; + + constructor( + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + } + + /** + * Sets the git repository. Subscribes to its state observable to react to + * ahead/behind changes. + */ + setRepository(repository: IGitRepository | undefined): void { + this._stateDisposables.clear(); + this._repository = repository; + + if (repository) { + this._stateDisposables.add(autorun(reader => { + repository.state.read(reader); + this._update(); + })); + } else { + this._update(); + } + } + + /** + * Sets the currently selected branch name (from the branch picker). + * The sync indicator is only shown when the selected branch is the HEAD branch. + */ + setBranch(branch: string | undefined): void { + this._selectedBranch = branch; + this._update(); + } + + /** + * Renders the sync indicator button into the given container. + */ + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-sync-indicator')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const button = dom.append(slot, dom.$('a.action-label')); + button.tabIndex = 0; + button.role = 'button'; + this._buttonElement = button; + + this._renderDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._executeSyncCommand(); + })); + + this._renderDisposables.add(dom.addDisposableListener(button, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._executeSyncCommand(); + } + })); + + this._update(); + } + + /** + * Shows or hides the sync indicator slot. + */ + setVisible(visible: boolean): void { + this._visible = visible; + this._update(); + } + + private async _executeSyncCommand(): Promise { + if (this._syncing) { + return; + } + this._syncing = true; + this._update(); + try { + await this.commandService.executeCommand(GIT_SYNC_COMMAND, this._repository?.rootUri); + } finally { + this._syncing = false; + this._update(); + } + } + + private _getAheadBehind(): { ahead: number; behind: number } | undefined { + if (!this._repository) { + return undefined; + } + + const head = this._repository.state.get().HEAD; + if (!head?.upstream) { + return undefined; + } + + // Only show sync for the HEAD branch (i.e. the selected branch must match the actual HEAD) + if (head.name !== this._selectedBranch) { + return undefined; + } + + const ahead = head.ahead ?? 0; + const behind = head.behind ?? 0; + if (ahead === 0 && behind === 0) { + return undefined; + } + + return { ahead, behind }; + } + + private _update(): void { + if (!this._slotElement || !this._buttonElement) { + return; + } + + const counts = this._getAheadBehind(); + if ((!counts && !this._syncing) || !this._visible) { + this._slotElement.style.display = 'none'; + return; + } + + this._slotElement.style.display = ''; + + dom.clearNode(this._buttonElement); + dom.append(this._buttonElement, renderIcon(this._syncing ? ThemeIcon.modify(Codicon.sync, 'spin') : Codicon.sync)); + + if (counts) { + const parts: string[] = []; + if (counts.behind > 0) { + parts.push(`${counts.behind}↓`); + } + if (counts.ahead > 0) { + parts.push(`${counts.ahead}↑`); + } + + const label = dom.append(this._buttonElement, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = parts.join('\u00a0'); + } + + this._buttonElement.title = localize( + 'syncIndicator.tooltip', + "Synchronize Changes ({0} to pull, {1} to push)", + counts?.behind ?? 0, + counts?.ahead ?? 0, + ); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/workspacePicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts new file mode 100644 index 0000000000000..a4eb5afd41095 --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; + +// Re-export from common for backward compatibility +export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; + +/** + * Prompt path for built-in prompts bundled with the Sessions app. + */ +export interface IBuiltinPromptPath { + readonly uri: URI; + readonly storage: AICustomizationPromptsStorage; + readonly type: PromptsType; + readonly name?: string; + readonly description?: string; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts new file mode 100644 index 0000000000000..e20913a9a98c8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { IRunScriptCustomTaskWidgetState, RunScriptCustomTaskWidget, WORKTREE_CREATED_RUN_ON } from '../../browser/runScriptCustomTaskWidget.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +const filledLabel = 'Start Dev Server'; +const filledCommand = 'npm run dev'; +const workspaceUnavailableReason = 'Workspace storage is unavailable for this session'; + +function renderWidget(ctx: ComponentFixtureContext, state: IRunScriptCustomTaskWidgetState): void { + ctx.container.style.width = '600px'; + ctx.container.style.padding = '0'; + ctx.container.style.borderRadius = 'var(--vscode-cornerRadius-xLarge)'; + ctx.container.style.backgroundColor = 'var(--vscode-quickInput-background)'; + ctx.container.style.overflow = 'hidden'; + + const widget = ctx.disposableStore.add(new RunScriptCustomTaskWidget(state)); + ctx.container.appendChild(widget.domNode); +} + +function defineFixture(state: IRunScriptCustomTaskWidgetState) { + return defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderWidget(ctx, state), + }); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + WorkspaceSelectedEmpty: defineFixture({ + target: 'workspace', + }), + + WorkspaceSelectedCheckedEmpty: defineFixture({ + target: 'workspace', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceSelectedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + }), + + WorkspaceSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedEmpty: defineFixture({ + target: 'user', + }), + + UserSelectedCheckedEmpty: defineFixture({ + target: 'user', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + }), + + UserSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + ExistingWorkspaceTaskLocked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'workspace', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + }), + + ExistingUserTaskLockedChecked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'user', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + runOn: WORKTREE_CREATED_RUN_ON, + }), +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 46e663337003c..e54d687ac135a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -13,23 +13,62 @@ import { IFileContent, IFileService } from '../../../../../platform/files/common import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IJSONEditingService, IJSONValue } from '../../../../../workbench/services/configuration/common/jsonEditing.js'; import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; -import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; -import { ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { INonSessionTaskEntry, ISessionsConfigurationService, SessionsConfigurationService, ITaskEntry } from '../../browser/sessionsConfigurationService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { observableValue } from '../../../../../base/common/observable.js'; - -function makeSession(opts: { repository?: URI; worktree?: URI } = {}): IActiveSessionItem { +import { Task } from '../../../../../workbench/contrib/tasks/common/tasks.js'; +import { ITaskService } from '../../../../../workbench/contrib/tasks/common/taskService.js'; +import { ISessionData, SessionStatus } from '../../../sessions/common/sessionData.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; + +function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISessionData { + const workspace = opts.repository ? { + label: 'test', + icon: Codicon.folder, + repositories: [{ + uri: opts.repository, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchProtected: undefined, + }], + } : undefined; return { - repository: opts.repository, - worktree: opts.worktree, - } as IActiveSessionItem; + sessionId: 'test:session', + resource: URI.parse('file:///session'), + providerId: 'test', + sessionType: 'background', + icon: Codicon.copilot, + createdAt: new Date(), + workspace: observableValue('workspace', workspace), + title: observableValue('title', 'session'), + updatedAt: observableValue('updatedAt', new Date()), + status: observableValue('status', SessionStatus.Untitled), + changes: observableValue('changes', []), + modelId: observableValue('modelId', undefined), + mode: observableValue('mode', undefined), + loading: observableValue('loading', false), + isArchived: observableValue('isArchived', false), + isRead: observableValue('isRead', true), + lastTurnEnd: observableValue('lastTurnEnd', undefined), + description: observableValue('description', undefined), + pullRequestUri: observableValue('pullRequestUri', undefined), + }; } function makeTask(label: string, command?: string, inSessions?: boolean): ITaskEntry { return { label, type: 'shell', command: command ?? label, inSessions }; } +function makeNpmTask(label: string, script: string, inSessions?: boolean): ITaskEntry { + return { label, type: 'npm', script, inSessions }; +} + +function makeUnsupportedTask(label: string, inSessions?: boolean): ITaskEntry { + return { label, type: 'gulp', command: label, inSessions }; +} + function tasksJsonContent(tasks: ITaskEntry[]): string { return JSON.stringify({ version: '2.0.0', tasks }); } @@ -40,10 +79,13 @@ suite('SessionsConfigurationService', () => { let service: ISessionsConfigurationService; let fileContents: Map; let jsonEdits: { uri: URI; values: IJSONValue[] }[]; - let createdTerminals: { name: string | undefined; cwd: URI | string | undefined }[]; - let sentCommands: { command: string }[]; - let committedFiles: { session: IActiveSessionItem; fileUris: URI[] }[]; + let ranTasks: { label: string }[]; + let committedFiles: { session: ISessionData; fileUris: URI[] }[]; let storageService: InMemoryStorageService; + let readFileCalls: URI[]; + let activeSessionObs: ReturnType>; + let tasksByLabel: Map; + let workspaceFoldersByUri: Map; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -52,14 +94,18 @@ suite('SessionsConfigurationService', () => { setup(() => { fileContents = new Map(); jsonEdits = []; - createdTerminals = []; - sentCommands = []; + ranTasks = []; committedFiles = []; + readFileCalls = []; + tasksByLabel = new Map(); + workspaceFoldersByUri = new Map(); const instantiationService = store.add(new TestInstantiationService()); + activeSessionObs = observableValue('activeSession', undefined); instantiationService.stub(IFileService, new class extends mock() { override async readFile(resource: URI) { + readFileCalls.push(resource); const content = fileContents.get(resource.toString()); if (content === undefined) { throw new Error('file not found'); @@ -80,32 +126,28 @@ suite('SessionsConfigurationService', () => { override userSettingsResource = userSettingsUri; }); - let nextInstanceId = 1; - const terminalInstances: (Partial & { instanceId: number })[] = []; - - const terminalServiceMock = new class extends mock() { - override get instances(): readonly ITerminalInstance[] { return terminalInstances as ITerminalInstance[]; } - override async createTerminal(opts?: { config?: { name?: string }; cwd?: URI }) { - const instance: Partial & { instanceId: number } = { - instanceId: nextInstanceId++, - initialCwd: opts?.cwd?.fsPath, - cwd: opts?.cwd?.fsPath, - hasChildProcesses: false, - sendText: async (text: string) => { sentCommands.push({ command: text }); }, - }; - createdTerminals.push({ name: opts?.config?.name, cwd: opts?.cwd }); - terminalInstances.push(instance); - return instance as ITerminalInstance; + instantiationService.stub(ITaskService, new class extends mock() { + override async getTask(_workspaceFolder: any, alias: string | any) { + const label = typeof alias === 'string' ? alias : ''; + return tasksByLabel.get(label); } - override setActiveInstance() { } - override async revealActiveTerminal() { } - }; + override async run(task: Task | undefined) { + if (task) { + ranTasks.push({ label: task._label }); + } + return undefined; + } + }); - instantiationService.stub(ITerminalService, terminalServiceMock); + instantiationService.stub(IWorkspaceContextService, new class extends mock() { + override getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { + return workspaceFoldersByUri.get(resource.toString()) ?? null; + } + }); instantiationService.stub(ISessionsManagementService, new class extends mock() { - override activeSession = observableValue('activeSession', undefined); - override async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]) { committedFiles.push({ session, fileUris }); } + override activeSession = activeSessionObs; + override async commitWorktreeFiles(session: ISessionData, fileUris: URI[]) { committedFiles.push({ session, fileUris }); } }); storageService = store.add(new InMemoryStorageService()); @@ -128,6 +170,8 @@ suite('SessionsConfigurationService', () => { makeTask('build', 'npm run build', true), makeTask('lint', 'npm run lint', false), makeTask('test', 'npm test', true), + makeNpmTask('watch', 'watch', true), + makeUnsupportedTask('gulp-task', true), ])); // user tasks.json — empty const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); @@ -140,7 +184,7 @@ suite('SessionsConfigurationService', () => { await new Promise(r => setTimeout(r, 10)); const tasks = obs.get(); - assert.deepStrictEqual(tasks.map(t => t.label), ['build', 'test']); + assert.deepStrictEqual(tasks.map(t => t.task.label), ['build', 'test', 'watch', 'gulp-task']); }); test('getSessionTasks returns empty array when no worktree', async () => { @@ -164,7 +208,29 @@ suite('SessionsConfigurationService', () => { const obs = service.getSessionTasks(session); await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(obs.get().map(t => t.label), ['serve']); + assert.deepStrictEqual(obs.get().map(t => t.task.label), ['serve']); + }); + + test('getSessionTasks does not re-read files on repeated calls for the same folder', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + + // Call getSessionTasks multiple times for the same session/folder + service.getSessionTasks(session); + service.getSessionTasks(session); + service.getSessionTasks(session); + + await new Promise(r => setTimeout(r, 10)); + + // _refreshSessionTasks reads two files (workspace + user tasks.json). + // If refresh triggered more than once, we'd see > 2 reads. + assert.strictEqual(readFileCalls.length, 2, 'should read files only once (no duplicate refresh)'); }); // --- getNonSessionTasks --- @@ -175,6 +241,8 @@ suite('SessionsConfigurationService', () => { makeTask('build', 'npm run build', true), makeTask('lint', 'npm run lint', false), makeTask('test', 'npm test'), + makeNpmTask('watch', 'watch', false), + makeUnsupportedTask('gulp-task', false), ])); const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); fileContents.set(userTasksUri.toString(), tasksJsonContent([])); @@ -182,7 +250,7 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ worktree: worktreeUri, repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint', 'test']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint', 'test', 'watch', 'gulp-task']); }); test('getNonSessionTasks reads from repository when no worktree', async () => { @@ -197,7 +265,26 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint']); + }); + + test('getNonSessionTasks preserves the source target for workspace and user tasks', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('workspaceTask', 'npm run workspace'), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userTask', 'npm run user'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const nonSessionTasks = await service.getNonSessionTasks(session); + + assert.deepStrictEqual(nonSessionTasks, [ + { task: { label: 'workspaceTask', type: 'shell', command: 'npm run workspace' }, target: 'workspace' }, + { task: { label: 'userTask', type: 'shell', command: 'npm run user' }, target: 'user' }, + ] satisfies INonSessionTaskEntry[]); }); // --- addTaskToSessions --- @@ -247,6 +334,36 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); + test('addTaskToSessions updates runOptions when provided', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'worktreeCreated' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: { runOn: 'worktreeCreated' } }, + ]); + }); + + test('addTaskToSessions clears runOptions when default is requested', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + { ...makeTask('build', 'npm run build'), runOptions: { runOn: 'worktreeCreated' } }, + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'default' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: undefined }, + ]); + }); + // --- createAndAddTask --- test('createAndAddTask writes new task with inSessions: true', async () => { @@ -256,7 +373,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); const edit = jsonEdits[0]; @@ -278,7 +395,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); assert.strictEqual(jsonEdits[0].uri.toString(), repoTasksUri.toString()); @@ -291,143 +408,235 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); - // --- runTask --- + test('createAndAddTask writes worktreeCreated run option when requested', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([])); - test('runTask creates terminal and sends command', async () => { const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - const task = makeTask('build', 'npm run build'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace', { runOn: 'worktreeCreated' }); - await service.runTask(task, session); - - assert.strictEqual(createdTerminals.length, 1); - assert.strictEqual(createdTerminals[0].name, 'build'); - assert.strictEqual(sentCommands.length, 1); - assert.strictEqual(sentCommands[0].command, 'npm run build'); + assert.strictEqual(jsonEdits.length, 1); + const tasksValue = jsonEdits[0].values.find(v => v.path[0] === 'tasks'); + assert.ok(tasksValue); + const tasks = tasksValue!.value as ITaskEntry[]; + assert.deepStrictEqual(tasks[0].runOptions, { runOn: 'worktreeCreated' }); }); - test('runTask does nothing when no cwd available', async () => { - const session = makeSession({ repository: undefined, worktree: undefined }); - await service.runTask(makeTask('build', 'npm run build'), session); + test('createAndAddTask writes a custom label when provided', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([])); - assert.strictEqual(createdTerminals.length, 0); - assert.strictEqual(sentCommands.length, 0); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.createAndAddTask('Start Dev Server', 'npm run dev', session, 'workspace'); + + assert.strictEqual(jsonEdits.length, 1); + const tasksValue = jsonEdits[0].values.find(v => v.path[0] === 'tasks'); + assert.ok(tasksValue); + const tasks = tasksValue!.value as ITaskEntry[]; + assert.strictEqual(tasks[0].label, 'Start Dev Server'); + assert.strictEqual(tasks[0].command, 'npm run dev'); }); - test('runTask reuses the same terminal for the same command and worktree', async () => { - const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - const task = makeTask('build', 'npm run build'); + // --- removeTask --- - await service.runTask(task, session); - await service.runTask(task, session); + test('removeTask deletes the matching task entry', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + makeTask('test', 'npm test', true), + makeTask('lint', 'npm run lint'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.removeTask('test', session, 'workspace'); - assert.strictEqual(createdTerminals.length, 1, 'should create only one terminal'); - assert.strictEqual(sentCommands.length, 2, 'should send command twice'); - assert.strictEqual(sentCommands[0].command, 'npm run build'); - assert.strictEqual(sentCommands[1].command, 'npm run build'); + assert.strictEqual(jsonEdits.length, 1); + assert.deepStrictEqual(jsonEdits[0].values, [{ + path: ['tasks'], + value: [ + makeTask('build', 'npm run build', true), + { label: 'lint', type: 'shell', command: 'npm run lint' }, + ], + }]); + assert.strictEqual(committedFiles.length, 1); + assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); - test('runTask creates different terminals for different commands', async () => { - const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + // --- updateTask --- - await service.runTask(makeTask('build', 'npm run build'), session); - await service.runTask(makeTask('test', 'npm test'), session); + test('updateTask replaces an existing task in place', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + makeTask('test', 'npm test', true), + ])); - assert.strictEqual(createdTerminals.length, 2, 'should create two terminals'); - assert.strictEqual(createdTerminals[0].name, 'build'); - assert.strictEqual(createdTerminals[1].name, 'test'); - }); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.updateTask('test', { + label: 'Test Changed', + type: 'shell', + command: 'pnpm test', + inSessions: true, + runOptions: { runOn: 'worktreeCreated' } + }, session, 'workspace', 'workspace'); - test('runTask creates different terminals for same command in different worktrees', async () => { - const wt1 = URI.parse('file:///worktree1'); - const wt2 = URI.parse('file:///worktree2'); - const session1 = makeSession({ worktree: wt1, repository: repoUri }); - const session2 = makeSession({ worktree: wt2, repository: repoUri }); + assert.strictEqual(jsonEdits.length, 1); + assert.deepStrictEqual(jsonEdits[0].values, [{ + path: ['tasks', 1], + value: { + label: 'Test Changed', + type: 'shell', + command: 'pnpm test', + inSessions: true, + runOptions: { runOn: 'worktreeCreated' } + } + }]); + assert.strictEqual(committedFiles.length, 1); + }); - await service.runTask(makeTask('build', 'npm run build'), session1); - await service.runTask(makeTask('build', 'npm run build'), session2); + test('updateTask moves a task between workspace and user storage', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userExisting', 'npm run user', true), + ])); - assert.strictEqual(createdTerminals.length, 2, 'should create two terminals for different worktrees'); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.updateTask('build', { + label: 'Build Changed', + type: 'shell', + command: 'pnpm build', + inSessions: true, + }, session, 'workspace', 'user'); + + assert.strictEqual(jsonEdits.length, 2); + assert.deepStrictEqual(jsonEdits[0], { + uri: worktreeTasksUri, + values: [{ + path: ['tasks'], + value: [] + }] + }); + assert.deepStrictEqual(jsonEdits[1], { + uri: userTasksUri, + values: [ + { path: ['version'], value: '2.0.0' }, + { + path: ['tasks'], + value: [ + makeTask('userExisting', 'npm run user', true), + { + label: 'Build Changed', + type: 'shell', + command: 'pnpm build', + inSessions: true, + } + ] + } + ] + }); + assert.strictEqual(committedFiles.length, 1); }); - // --- getLastRunTaskLabel (MRU) --- + // --- pinned task --- - test('getLastRunTaskLabel returns undefined when no task has been run', () => { - const obs = service.getLastRunTaskLabel(repoUri); + test('getPinnedTaskLabel returns undefined when no task is pinned', () => { + const obs = service.getPinnedTaskLabel(repoUri); assert.strictEqual(obs.get(), undefined); }); - test('getLastRunTaskLabel returns label after runTask', async () => { - const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - const obs = service.getLastRunTaskLabel(repoUri); + test('setPinnedTaskLabel stores and clears the pinned task label', () => { + const obs = service.getPinnedTaskLabel(repoUri); - await service.runTask(makeTask('build', 'npm run build'), session); + service.setPinnedTaskLabel(repoUri, 'build'); assert.strictEqual(obs.get(), 'build'); - await service.runTask(makeTask('test', 'npm test'), session); - assert.strictEqual(obs.get(), 'test'); + service.setPinnedTaskLabel(repoUri, undefined); + assert.strictEqual(obs.get(), undefined); }); - test('getLastRunTaskLabel returns undefined for undefined repository', () => { - const obs = service.getLastRunTaskLabel(undefined); - assert.strictEqual(obs.get(), undefined); + test('updateTask keeps the pinned task in sync when the label changes', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + ])); + service.setPinnedTaskLabel(repoUri, 'build'); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.updateTask('build', { + label: 'build:watch', + type: 'shell', + command: 'npm run watch', + inSessions: true, + }, session, 'workspace', 'workspace'); + + assert.strictEqual(service.getPinnedTaskLabel(repoUri).get(), 'build:watch'); }); - test('getLastRunTaskLabel tracks separate repositories independently', async () => { - const repo1 = URI.parse('file:///repo1'); - const repo2 = URI.parse('file:///repo2'); - const wt1 = URI.parse('file:///wt1'); - const wt2 = URI.parse('file:///wt2'); + test('removeTask clears the pinned task when deleting the pinned entry', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build', true), + ])); + service.setPinnedTaskLabel(repoUri, 'build'); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.removeTask('build', session, 'workspace'); + + assert.strictEqual(service.getPinnedTaskLabel(repoUri).get(), undefined); + }); - const session1 = makeSession({ worktree: wt1, repository: repo1 }); - const session2 = makeSession({ worktree: wt2, repository: repo2 }); + // --- runTask --- - const obs1 = service.getLastRunTaskLabel(repo1); - const obs2 = service.getLastRunTaskLabel(repo2); + function registerMockTask(label: string, folder: URI): void { + tasksByLabel.set(label, { _label: label } as unknown as Task); + workspaceFoldersByUri.set(folder.toString(), { uri: folder, name: 'folder', index: 0, toResource: () => folder } as IWorkspaceFolder); + } - await service.runTask(makeTask('build', 'npm run build'), session1); - await service.runTask(makeTask('test', 'npm test'), session2); + test('runTask looks up task by label and runs it via the task service', async () => { + registerMockTask('build', worktreeUri); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + + await service.runTask(makeTask('build', 'npm run build'), session); - assert.strictEqual(obs1.get(), 'build'); - assert.strictEqual(obs2.get(), 'test'); + assert.strictEqual(ranTasks.length, 1); + assert.strictEqual(ranTasks[0].label, 'build'); }); - test('getLastRunTaskLabel returns same observable for same repository', () => { - const obs1 = service.getLastRunTaskLabel(repoUri); - const obs2 = service.getLastRunTaskLabel(repoUri); - assert.strictEqual(obs1, obs2); + test('runTask does nothing when no cwd available', async () => { + const session = makeSession({ repository: undefined, worktree: undefined }); + await service.runTask(makeTask('build', 'npm run build'), session); + + assert.strictEqual(ranTasks.length, 0); }); - test('getLastRunTaskLabel persists across service instances', async () => { + test('runTask does nothing when workspace folder not found', async () => { + // No workspace folder registered for worktreeUri const session = makeSession({ worktree: worktreeUri, repository: repoUri }); await service.runTask(makeTask('build', 'npm run build'), session); - // Create a second service instance using the same storage - const instantiationService = store.add(new TestInstantiationService()); - instantiationService.stub(IFileService, new class extends mock() { - override async readFile(): Promise { throw new Error('not found'); } - override watch() { return { dispose() { } }; } - override onDidFilesChange: any = () => ({ dispose() { } }); - }); - instantiationService.stub(IJSONEditingService, new class extends mock() { - override async write() { } - }); - instantiationService.stub(IPreferencesService, new class extends mock() { - override userSettingsResource = userSettingsUri; - }); - instantiationService.stub(ITerminalService, new class extends mock() { - override instances: readonly ITerminalInstance[] = []; - override async createTerminal() { return {} as ITerminalInstance; } - override setActiveInstance() { } - override async revealActiveTerminal() { } - }); - instantiationService.stub(ISessionsManagementService, new class extends mock() { - override activeSession = observableValue('activeSession', undefined); - override async commitWorktreeFiles() { } - }); - instantiationService.stub(IStorageService, storageService); + assert.strictEqual(ranTasks.length, 0); + }); - const service2 = store.add(instantiationService.createInstance(SessionsConfigurationService)); - const obs = service2.getLastRunTaskLabel(repoUri); - assert.strictEqual(obs.get(), 'build'); + test('runTask does nothing when task not found by label', async () => { + workspaceFoldersByUri.set(worktreeUri.toString(), { uri: worktreeUri, name: 'folder', index: 0, toResource: () => worktreeUri } as IWorkspaceFolder); + // No task registered for 'nonexistent' + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.runTask(makeTask('nonexistent', 'echo hi'), session); + + assert.strictEqual(ranTasks.length, 0); + }); + + test('runTask uses repository as cwd when worktree is not available', async () => { + registerMockTask('build', repoUri); + const session = makeSession({ repository: repoUri }); + + await service.runTask(makeTask('build', 'npm run build'), session); + + assert.strictEqual(ranTasks.length, 1); + assert.strictEqual(ranTasks[0].label, 'build'); }); }); diff --git a/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts new file mode 100644 index 0000000000000..dbb92718c71b6 --- /dev/null +++ b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; + +const COPILOT_CHAT_VIEW_CONTAINER_ID = 'workbench.view.extension.copilot-chat'; +const COPILOT_CHAT_VIEW_ID = 'copilot-chat'; +const SESSIONS_CHAT_DEBUG_CONTAINER_ID = 'workbench.sessions.panel.chatDebugContainer'; + +const chatDebugViewIcon = registerIcon('sessions-chat-debug-view-icon', Codicon.debug, localize('sessionsChatDebugViewIcon', 'View icon of the chat debug view in the sessions window.')); + +class RegisterChatDebugViewContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.registerChatDebugView'; + + constructor() { + super(); + + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + + // The copilot-chat view is contributed by the Copilot Chat extension, + // which may register after this contribution runs. Handle both cases. + if (!this.tryMoveView(viewContainerRegistry, viewsRegistry)) { + const listener = viewsRegistry.onViewsRegistered(e => { + for (const { views } of e) { + if (views.some(v => v.id === COPILOT_CHAT_VIEW_ID)) { + if (this.tryMoveView(viewContainerRegistry, viewsRegistry)) { + listener.dispose(); + } + break; + } + } + }); + this._register(listener); + } + } + + private tryMoveView(viewContainerRegistry: IViewContainersRegistry, viewsRegistry: IViewsRegistry): boolean { + const viewContainer = viewContainerRegistry.get(COPILOT_CHAT_VIEW_CONTAINER_ID); + if (!viewContainer) { + return false; + } + + const view = viewsRegistry.getView(COPILOT_CHAT_VIEW_ID); + if (!view) { + return false; + } + + // Deregister the view from its original extension container + viewsRegistry.deregisterViews([view], viewContainer); + viewContainerRegistry.deregisterViewContainer(viewContainer); + + // Register a new chat debug view container in the Panel for the sessions window + const chatDebugViewContainer = viewContainerRegistry.registerViewContainer({ + id: SESSIONS_CHAT_DEBUG_CONTAINER_ID, + title: localize2('chatDebug', "Chat Debug"), + icon: chatDebugViewIcon, + order: 3, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_CHAT_DEBUG_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: SESSIONS_CHAT_DEBUG_CONTAINER_ID, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); + + // Re-register the view inside the new sessions container + const sessionsView: IViewDescriptor = { + ...view, + canMoveView: false, + windowVisibility: WindowVisibility.Sessions, + }; + viewsRegistry.registerViews([sessionsView], chatDebugViewContainer); + + return true; + } +} + +registerWorkbenchContribution2(RegisterChatDebugViewContribution.ID, RegisterChatDebugViewContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts new file mode 100644 index 0000000000000..b1af990d5dda1 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; +import { getSessionEditorComments } from '../../agentFeedback/browser/sessionEditorComments.js'; +import { CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, MAX_CODE_REVIEWS_PER_SESSION_VERSION, PRReviewStateKind } from './codeReviewService.js'; + +registerSingleton(ICodeReviewService, CodeReviewService, InstantiationType.Delayed); + +const canRunSessionCodeReviewContextKey = new RawContextKey('sessions.canRunCodeReview', true, { + type: 'boolean', + description: localize('sessions.canRunCodeReview', "True when a new code review can be started for the active session version."), +}); + +function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disposable { + class RunSessionCodeReviewAction extends Action2 { + static readonly ID = 'sessions.codeReview.run'; + + constructor() { + super({ + id: RunSessionCodeReviewAction.ID, + title: localize('sessions.runCodeReview', "Run Code Review"), + tooltip, + category: CHAT_CATEGORY, + icon, + precondition: canRunSessionCodeReviewContextKey, + menu: [ + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 7, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ChatContextKeys.hasAgentSessionChanges, + ChatContextKeys.agentSessionType.notEqualsTo(AgentSessionProviders.Cloud), + ), + }, + ], + }); + } + + override async run(accessor: ServicesAccessor, sessionResource?: URI): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const codeReviewService = accessor.get(ICodeReviewService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + + const resource = URI.isUri(sessionResource) + ? sessionResource + : sessionManagementService.activeSession.get()?.resource; + if (!resource) { + return; + } + + // Get changes from ISessionData + const sessionData = sessionManagementService.getSession(resource); + const changes = sessionData?.changes.get(); + if (!changes || changes.length === 0) { + return; + } + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + // If there are existing comments (code review or PR review), navigate to the first one + const reviewState = codeReviewService.getReviewState(resource).get(); + const prReviewState = codeReviewService.getPRReviewState(resource).get(); + const reviewCount = reviewState.kind !== CodeReviewStateKind.Idle && reviewState.version === version ? reviewState.reviewCount : 0; + const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; + const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + + if (codeReviewCount > 0 || prReviewCount > 0) { + const comments = getSessionEditorComments( + resource, + agentFeedbackService.getFeedback(resource), + reviewState, + prReviewState, + ); + const first = agentFeedbackService.getNextNavigableItem(resource, comments, true); + if (first) { + await agentFeedbackService.revealSessionComment(resource, first.id, first.resourceUri, first.range); + } + return; + } + + if (reviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { + return; + } + + + codeReviewService.requestReview(resource, version, files); + } + } + + return registerAction2(RunSessionCodeReviewAction) as Disposable; +} + +class CodeReviewToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.codeReviewToolbar'; + + private readonly _actionRegistration = this._register(new MutableDisposable()); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + ) { + super(); + + const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); + + this._register(autorun(reader => { + const activeSession = this._sessionManagementService.activeSession.read(reader); + this._actionRegistration.clear(); + + const sessionResource = activeSession?.resource; + if (!sessionResource) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noSession', "No active session available for code review."), Codicon.codeReview); + return; + } + + const changes = activeSession.changes.read(reader); + if (changes.length === 0) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noChanges', "No changes available for code review."), Codicon.codeReview); + return; + } + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + const reviewState = this._codeReviewService.getReviewState(sessionResource).read(reader); + const prReviewState = this._codeReviewService.getPRReviewState(sessionResource).read(reader); + const reviewCount = reviewState.kind !== CodeReviewStateKind.Idle && reviewState.version === version ? reviewState.reviewCount : 0; + + const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; + const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + const totalCommentCount = codeReviewCount + prReviewCount; + + let canRunCodeReview = true; + let tooltip = localize('sessions.runCodeReview.tooltip.default', "Run Code Review"); + let icon = Codicon.codeReview; + + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === version) { + canRunCodeReview = false; + tooltip = localize('sessions.runCodeReview.tooltip.loading', "Creating code review..."); + icon = Codicon.commentDraft; + } else if (totalCommentCount > 0) { + canRunCodeReview = true; + icon = Codicon.commentUnresolved; + tooltip = totalCommentCount === 1 + ? localize('sessions.runCodeReview.tooltip.oneUnresolved', "1 review comment unresolved.") + : localize('sessions.runCodeReview.tooltip.manyUnresolved', "{0} review comments unresolved.", totalCommentCount); + } else if (reviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { + canRunCodeReview = false; + tooltip = localize('sessions.runCodeReview.tooltip.limitReached', "Maximum of {0} code reviews reached for this session version.", MAX_CODE_REVIEWS_PER_SESSION_VERSION); + icon = Codicon.codeReview; + } else if (reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version) { + canRunCodeReview = true; + tooltip = reviewState.didProduceComments + ? localize('sessions.runCodeReview.tooltip.runAgain', "Run another code review.") + : localize('sessions.runCodeReview.tooltip.noCommentsRunAgain', "Previous code review produced no comments. Run code review again."); + icon = reviewState.didProduceComments ? Codicon.comment : Codicon.codeReview; + } + + canRunCodeReviewContext.set(canRunCodeReview); + this._actionRegistration.value = registerSessionCodeReviewAction(tooltip, icon); + })); + } +} + +registerWorkbenchContribution2(CodeReviewToolbarContribution.ID, CodeReviewToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts new file mode 100644 index 0000000000000..8505a973c52e9 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -0,0 +1,726 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { hash } from '../../../../base/common/hash.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +// --- Types ------------------------------------------------------------------- + +export interface ICodeReviewComment { + readonly id: string; + readonly uri: URI; + readonly range: IRange; + readonly body: string; + readonly kind: string; + readonly severity: string; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface ICodeReviewSuggestion { + readonly edits: readonly ICodeReviewSuggestionChange[]; +} + +export interface ICodeReviewSuggestionChange { + readonly range: IRange; + readonly newText: string; + readonly oldText: string; +} + +export interface ICodeReviewFile { + readonly currentUri: URI; + readonly baseUri?: URI; +} + +export function getCodeReviewFilesFromSessionChanges(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): readonly ICodeReviewFile[] { + return changes.map(change => { + if (isIChatSessionFileChange2(change)) { + return { + currentUri: change.modifiedUri ?? change.uri, + baseUri: change.originalUri, + }; + } + + return { + currentUri: change.modifiedUri, + baseUri: change.originalUri, + }; + }); +} + +export function getCodeReviewVersion(files: readonly ICodeReviewFile[]): string { + const stableFileList = files + .map(file => `${file.currentUri.toString()}|${file.baseUri?.toString() ?? ''}`) + .sort(); + + return `v1:${stableFileList.length}:${hash(stableFileList)}`; +} + +export const MAX_CODE_REVIEWS_PER_SESSION_VERSION = 5; + +export const enum CodeReviewStateKind { + Idle = 'idle', + Loading = 'loading', + Result = 'result', + Error = 'error', +} + +export type ICodeReviewState = + | { readonly kind: CodeReviewStateKind.Idle } + | { readonly kind: CodeReviewStateKind.Loading; readonly version: string; readonly reviewCount: number } + | { readonly kind: CodeReviewStateKind.Result; readonly version: string; readonly reviewCount: number; readonly comments: readonly ICodeReviewComment[]; readonly didProduceComments: boolean } + | { readonly kind: CodeReviewStateKind.Error; readonly version: string; readonly reviewCount: number; readonly reason: string }; + +// --- PR Review Types --------------------------------------------------------- + +export const enum PRReviewStateKind { + None = 'none', + Loading = 'loading', + Loaded = 'loaded', + Error = 'error', +} + +export type IPRReviewState = + | { readonly kind: PRReviewStateKind.None } + | { readonly kind: PRReviewStateKind.Loading } + | { readonly kind: PRReviewStateKind.Loaded; readonly comments: readonly IPRReviewComment[] } + | { readonly kind: PRReviewStateKind.Error; readonly reason: string }; + +export interface IPRReviewComment { + readonly id: string; + readonly uri: URI; + readonly range: IRange; + readonly body: string; + readonly author: string; +} + +/** Shape of a single comment as returned by the code review command. */ +interface IRawCodeReviewComment { + readonly uri: IRawCodeReviewUri; + readonly range: IRawCodeReviewRange; + readonly body?: string; + readonly kind?: string; + readonly severity?: string; + readonly suggestion?: IRawCodeReviewSuggestion; +} + +type IRawCodeReviewUri = URI | UriComponents | string; + +interface IRawCodeReviewPosition { + readonly line?: number; + readonly character?: number; +} + +interface IRawCodeReviewRangeWithPositions { + readonly start?: IRawCodeReviewPosition; + readonly end?: IRawCodeReviewPosition; +} + +interface IRawCodeReviewRangeWithLines { + readonly startLine?: number; + readonly startColumn?: number; + readonly endLine?: number; + readonly endColumn?: number; +} + +type IRawCodeReviewRangeTuple = readonly [IRawCodeReviewPosition, IRawCodeReviewPosition]; + +type IRawCodeReviewRange = IRange | IRawCodeReviewRangeWithPositions | IRawCodeReviewRangeWithLines | IRawCodeReviewRangeTuple; + +interface IRawCodeReviewSuggestion { + readonly edits: readonly IRawCodeReviewSuggestionChange[]; +} + +interface IRawCodeReviewSuggestionChange { + readonly range: IRawCodeReviewRange; + readonly newText: string; + readonly oldText: string; +} + +// --- Service Interface ------------------------------------------------------- + +export const ICodeReviewService = createDecorator('codeReviewService'); + +export interface ICodeReviewService { + readonly _serviceBrand: undefined; + + /** + * Get the observable review state for a session. + */ + getReviewState(sessionResource: URI): IObservable; + + /** + * Synchronously check if a completed review exists for the given session+version. + */ + hasReview(sessionResource: URI, version: string): boolean; + + /** + * Request a code review for the given session. The review is associated with + * a version string (fingerprint of changed files). If a review is already in + * progress or there are still unresolved review comments for this version, + * this is a no-op. + */ + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void; + + /** + * Remove a single comment from the review results. + */ + removeComment(sessionResource: URI, commentId: string): void; + + /** + * Update the body text of a single code review comment. + */ + updateComment(sessionResource: URI, commentId: string, newBody: string): void; + + /** + * Dismiss/clear the review for a session entirely. + */ + dismissReview(sessionResource: URI): void; + + /** + * Get the observable PR review state for a session. + * Returns unresolved review comments from the PR associated with the session. + */ + getPRReviewState(sessionResource: URI): IObservable; + + /** + * Resolve a PR review thread on GitHub and remove it from local state. + */ + resolvePRReviewThread(sessionResource: URI, threadId: string): Promise; + + /** + * Mark a PR review comment as locally converted to agent feedback. + * The comment is hidden from the PR review state until the session is + * cleaned up. + */ + markPRReviewCommentConverted(sessionResource: URI, commentId: string): void; +} + +// --- Storage Types ----------------------------------------------------------- + +interface IStoredCodeReview { + readonly version: string; + readonly reviewCount?: number; + readonly didProduceComments?: boolean; + readonly comments: readonly IStoredCodeReviewComment[]; +} + +interface IStoredCodeReviewComment { + readonly id: string; + readonly uri: UriComponents; + readonly range: IRange; + readonly body: string; + readonly kind: string; + readonly severity: string; + readonly suggestion?: ICodeReviewSuggestion; +} + +// --- Implementation ---------------------------------------------------------- + +interface ISessionReviewData { + readonly state: ReturnType>; +} + +interface IPRSessionReviewData { + readonly state: ReturnType>; + readonly disposables: DisposableStore; + initialized: boolean; +} + +function isRawCodeReviewRangeWithPositions(range: IRawCodeReviewRange): range is IRawCodeReviewRangeWithPositions { + return typeof range === 'object' && range !== null && hasKey(range, { start: true, end: true }); +} + +function isRawCodeReviewRangeTuple(range: IRawCodeReviewRange): range is IRawCodeReviewRangeTuple { + return Array.isArray(range) && range.length >= 2; +} + +function normalizeCodeReviewUri(uri: IRawCodeReviewUri): URI { + return typeof uri === 'string' ? URI.parse(uri) : URI.revive(uri); +} + +function normalizeCodeReviewRange(range: IRawCodeReviewRange): IRange { + if (Range.isIRange(range)) { + return Range.lift(range); + } + + if (isRawCodeReviewRangeTuple(range)) { + const [start, end] = range; + return new Range( + (start.line ?? 0) + 1, + (start.character ?? 0) + 1, + (end.line ?? start.line ?? 0) + 1, + (end.character ?? start.character ?? 0) + 1, + ); + } + + if (isRawCodeReviewRangeWithPositions(range) && range.start && range.end) { + return new Range( + (range.start.line ?? 0) + 1, + (range.start.character ?? 0) + 1, + (range.end.line ?? range.start.line ?? 0) + 1, + (range.end.character ?? range.start.character ?? 0) + 1, + ); + } + + const lineRange = range as IRawCodeReviewRangeWithLines; + return new Range( + (lineRange.startLine ?? 0) + 1, + (lineRange.startColumn ?? 0) + 1, + (lineRange.endLine ?? lineRange.startLine ?? 0) + 1, + (lineRange.endColumn ?? lineRange.startColumn ?? 0) + 1, + ); +} + +function normalizeCodeReviewSuggestion(suggestion: IRawCodeReviewSuggestion | undefined): ICodeReviewSuggestion | undefined { + if (!suggestion) { + return undefined; + } + + return { + edits: suggestion.edits.map(edit => ({ + range: normalizeCodeReviewRange(edit.range), + newText: edit.newText, + oldText: edit.oldText, + })), + }; +} + +export class CodeReviewService extends Disposable implements ICodeReviewService { + + declare readonly _serviceBrand: undefined; + + private static readonly _STORAGE_KEY = 'codeReview.reviews'; + + private readonly _reviewsBySession = new Map(); + private readonly _prReviewBySession = new Map(); + /** PR review comment IDs that have been converted to agent feedback (per session). */ + private readonly _convertedPRCommentsBySession = new Map>(); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, + @IStorageService private readonly _storageService: IStorageService, + @IGitHubService private readonly _gitHubService: IGitHubService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + ) { + super(); + this._loadFromStorage(); + this._registerSessionListeners(); + + this._register(autorun(reader => { + const activeSession = this._sessionsManagementService.activeSession.read(reader); + if (activeSession) { + this._ensurePRReviewInitialized(activeSession.resource); + } + })); + + this._register(this._sessionsManagementService.onDidChangeSessions(e => { + const archived = e.changed.filter(s => s.isArchived.get()); + const nonArchived = e.changed.filter(s => !s.isArchived.get()); + // Initialize PR review for new/changed sessions + for (const session of [...e.added, ...nonArchived]) { + this._ensurePRReviewInitialized(session.resource); + } + // Dispose PR review for removed and archived sessions + for (const session of [...e.removed, ...archived]) { + this._disposePRReview(session.resource); + } + })); + } + + getReviewState(sessionResource: URI): IObservable { + return this._getOrCreateData(sessionResource).state; + } + + hasReview(sessionResource: URI, version: string): boolean { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return false; + } + const state = data.state.get(); + return state.kind === CodeReviewStateKind.Result && state.version === version; + } + + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void { + const data = this._getOrCreateData(sessionResource); + const currentState = data.state.get(); + const currentReviewCount = currentState.kind !== CodeReviewStateKind.Idle && currentState.version === version ? currentState.reviewCount : 0; + + // Don't re-request if already loading or unresolved comments remain for this version. + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + return; + } + if (currentReviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { + return; + } + if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version && currentState.comments.length > 0) { + return; + } + + data.state.set({ kind: CodeReviewStateKind.Loading, version, reviewCount: currentReviewCount + 1 }, undefined); + + this._executeReview(sessionResource, version, files, data); + } + + removeComment(sessionResource: URI, commentId: string): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return; + } + + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + return; + } + + const filtered = state.comments.filter(c => c.id !== commentId); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: filtered, didProduceComments: state.didProduceComments }, undefined); + this._saveToStorage(); + } + + updateComment(sessionResource: URI, commentId: string, newBody: string): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return; + } + + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + return; + } + + const updated = state.comments.map(c => c.id === commentId ? { ...c, body: newBody } : c); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: updated, didProduceComments: state.didProduceComments }, undefined); + this._saveToStorage(); + } + + dismissReview(sessionResource: URI): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (data) { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + this._saveToStorage(); + } + } + + private _getOrCreateData(sessionResource: URI): ISessionReviewData { + const key = sessionResource.toString(); + let data = this._reviewsBySession.get(key); + if (!data) { + data = { + state: observableValue(`codeReview.state.${key}`, { kind: CodeReviewStateKind.Idle }), + }; + this._reviewsBySession.set(key, data); + } + return data; + } + + private async _executeReview( + sessionResource: URI, + version: string, + files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[], + data: ISessionReviewData, + ): Promise { + try { + const result: { type: string; comments?: IRawCodeReviewComment[]; reason?: string } | undefined = + await this._commandService.executeCommand('chat.internal.codeReview.run', { + files: files.map(f => ({ + currentUri: f.currentUri, + baseUri: f.baseUri, + })), + }); + + // Check if version is still current (hasn't been dismissed or replaced) + const currentState = data.state.get(); + if (currentState.kind !== CodeReviewStateKind.Loading || currentState.version !== version) { + return; + } + + if (!result || result.type === 'cancelled') { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + return; + } + + if (result.type === 'error') { + data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: result.reason ?? 'Unknown error' }, undefined); + return; + } + + if (result.type === 'success') { + const comments: ICodeReviewComment[] = (result.comments ?? []).map((raw) => ({ + id: generateUuid(), + uri: normalizeCodeReviewUri(raw.uri), + range: normalizeCodeReviewRange(raw.range), + body: raw.body ?? '', + kind: raw.kind ?? '', + severity: raw.severity ?? '', + suggestion: normalizeCodeReviewSuggestion(raw.suggestion), + })); + + transaction(tx => { + data.state.set({ kind: CodeReviewStateKind.Result, version, reviewCount: currentState.reviewCount, comments, didProduceComments: comments.length > 0 }, tx); + }); + this._saveToStorage(); + } + } catch (err) { + const currentState = data.state.get(); + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: String(err) }, undefined); + } + } + } + + private _loadFromStorage(): void { + const raw = this._storageService.get(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE); + if (!raw) { + return; + } + + try { + const stored: Record = JSON.parse(raw); + for (const [key, review] of Object.entries(stored)) { + const comments: ICodeReviewComment[] = review.comments.map(c => ({ + id: c.id, + uri: URI.revive(c.uri), + range: c.range, + body: c.body, + kind: c.kind, + severity: c.severity, + suggestion: c.suggestion, + })); + const data = this._getOrCreateData(URI.parse(key)); + data.state.set({ kind: CodeReviewStateKind.Result, version: review.version, reviewCount: review.reviewCount ?? 1, comments, didProduceComments: review.didProduceComments ?? comments.length > 0 }, undefined); + } + } catch { + // Corrupted storage data - ignore + } + } + + private _saveToStorage(): void { + const stored: Record = {}; + for (const [key, data] of this._reviewsBySession) { + const state = data.state.get(); + if (state.kind === CodeReviewStateKind.Result) { + stored[key] = { + version: state.version, + reviewCount: state.reviewCount, + didProduceComments: state.didProduceComments, + comments: state.comments.map(c => ({ + id: c.id, + uri: c.uri.toJSON(), + range: c.range, + body: c.body, + kind: c.kind, + severity: c.severity, + suggestion: c.suggestion, + })), + }; + } + } + + if (Object.keys(stored).length === 0) { + this._storageService.remove(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE); + } else { + this._storageService.store(CodeReviewService._STORAGE_KEY, JSON.stringify(stored), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } + + private _registerSessionListeners(): void { + // Clean up when sessions change (archived/removed sessions, stale review versions) + this._register(this._sessionsManagementService.onDidChangeSessions(e => { + // Clean up reviews for removed/archived sessions + for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { + const key = session.resource.toString(); + const data = this._reviewsBySession.get(key); + if (data) { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + this._saveToStorage(); + } + } + + // Check for stale review versions when sessions change + let changed = false; + for (const [key, data] of this._reviewsBySession) { + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + continue; + } + + const session = this._sessionsManagementService.getSession(URI.parse(key)); + if (!session) { + // Session no longer exists - clean up + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + continue; + } + + const changes = session.changes.get(); + if (changes.length === 0) { + // Session has no file-level changes - clean up + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + continue; + } + + const files = getCodeReviewFilesFromSessionChanges(changes); + const currentVersion = getCodeReviewVersion(files); + if (state.version !== currentVersion) { + // Version mismatch - review is stale + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + } + } + + if (changed) { + this._saveToStorage(); + } + })); + } + + getPRReviewState(sessionResource: URI): IObservable { + return this._getOrCreatePRReviewData(sessionResource).state; + } + + async resolvePRReviewThread(sessionResource: URI, threadId: string): Promise { + const context = this._sessionsManagementService.getGitHubContextForSession(sessionResource); + if (context?.prNumber !== undefined) { + const prModel = this._gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + try { + await prModel.resolveThread(threadId); + } catch (err) { + this._logService.warn('[CodeReviewService] Failed to resolve PR thread on GitHub:', err); + } + } + + // Remove from local state regardless of GitHub success + const data = this._prReviewBySession.get(sessionResource.toString()); + if (data) { + const currentState = data.state.get(); + if (currentState.kind === PRReviewStateKind.Loaded) { + const filtered = currentState.comments.filter(c => c.id !== threadId); + data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined); + } + } + } + + markPRReviewCommentConverted(sessionResource: URI, commentId: string): void { + const key = sessionResource.toString(); + let converted = this._convertedPRCommentsBySession.get(key); + if (!converted) { + converted = new Set(); + this._convertedPRCommentsBySession.set(key, converted); + } + converted.add(commentId); + + // Immediately filter the comment from the observable PR review state + const data = this._prReviewBySession.get(key); + if (data) { + const currentState = data.state.get(); + if (currentState.kind === PRReviewStateKind.Loaded) { + const filtered = currentState.comments.filter(c => c.id !== commentId); + data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined); + } + } + } + + private _getOrCreatePRReviewData(sessionResource: URI): IPRSessionReviewData { + const key = sessionResource.toString(); + let data = this._prReviewBySession.get(key); + if (!data) { + data = { + state: observableValue(`prReview.state.${key}`, { kind: PRReviewStateKind.None }), + disposables: new DisposableStore(), + initialized: false, + }; + this._prReviewBySession.set(key, data); + } + return data; + } + + private _ensurePRReviewInitialized(sessionResource: URI): void { + const data = this._getOrCreatePRReviewData(sessionResource); + if (data.initialized) { + return; + } + + const context = this._sessionsManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + data.initialized = true; + data.state.set({ kind: PRReviewStateKind.Loading }, undefined); + + const prModel = this._gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + + // Watch the PR model's review threads and map to local state + data.disposables.add(autorun(reader => { + const threads = prModel.reviewThreads.read(reader); + const converted = this._convertedPRCommentsBySession.get(sessionResource.toString()); + const comments: IPRReviewComment[] = []; + + for (const thread of threads) { + if (thread.isResolved) { + continue; + } + const threadId = String(thread.id); + if (converted?.has(threadId)) { + continue; + } + const fileUri = this._sessionsManagementService.resolveSessionFileUri(sessionResource, thread.path); + if (!fileUri) { + continue; + } + const line = thread.line ?? 1; + const firstComment = thread.comments[0]; + comments.push({ + id: String(thread.id), + uri: fileUri, + range: new Range(line, 1, line, 1), + body: firstComment?.body ?? '', + author: firstComment?.author.login ?? '', + }); + } + + data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); + })); + + // Start polling and initial fetch + prModel.refreshThreads().catch(err => { + this._logService.error('[CodeReviewService] Failed to fetch PR review threads:', err); + data.state.set({ kind: PRReviewStateKind.Error, reason: String(err) }, undefined); + }); + prModel.startPolling(); + } + + private _disposePRReview(sessionResource: URI): void { + const key = sessionResource.toString(); + this._convertedPRCommentsBySession.delete(key); + const data = this._prReviewBySession.get(key); + if (data) { + data.disposables.dispose(); + this._prReviewBySession.delete(key); + } + } + + override dispose(): void { + for (const data of this._prReviewBySession.values()) { + data.disposables.dispose(); + } + this._prReviewBySession.clear(); + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts new file mode 100644 index 0000000000000..6cfbc692a91ca --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -0,0 +1,1041 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionData } from '../../../sessions/common/sessionData.js'; +import { ISessionsChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; +import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js'; + +suite('CodeReviewService', () => { + + const store = new DisposableStore(); + let instantiationService: TestInstantiationService; + let service: ICodeReviewService; + let commandService: MockCommandService; + let storageService: InMemoryStorageService; + let sessionsManagement: MockSessionsManagementService; + + let session: URI; + let fileA: URI; + let fileB: URI; + + class MockCommandService implements ICommandService { + declare readonly _serviceBrand: undefined; + readonly onWillExecuteCommand = Event.None; + readonly onDidExecuteCommand = Event.None; + + result: unknown = undefined; + lastCommandId: string | undefined; + lastArgs: unknown[] | undefined; + executeDeferred: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | undefined; + + async executeCommand(commandId: string, ...args: unknown[]): Promise { + this.lastCommandId = commandId; + this.lastArgs = args; + + if (this.executeDeferred) { + return await new Promise((resolve, reject) => { + this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } + + return this.result as T; + } + + /** + * Configure the mock to defer execution until manually resolved/rejected. + */ + deferNextExecution(): void { + this.executeDeferred = undefined; + const self = this; + const originalResult = this.result; + + // Override executeCommand for next call to capture the deferred promise + const origExecute = this.executeCommand.bind(this); + this.executeCommand = async function (commandId: string, ...args: unknown[]): Promise { + self.lastCommandId = commandId; + self.lastArgs = args; + + return new Promise((resolve, reject) => { + self.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } as typeof origExecute; + + // Restore after use + this._restoreExecute = () => { + this.executeCommand = origExecute; + this.result = originalResult; + }; + } + + private _restoreExecute: (() => void) | undefined; + + resolveExecution(value: unknown): void { + this.executeDeferred?.resolve(value); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + + rejectExecution(error: unknown): void { + this.executeDeferred?.reject(error); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + } + + class MockSessionsManagementService extends mock() { + private readonly _onDidChangeSessions: Emitter; + override readonly onDidChangeSessions: Event; + override readonly activeSession: IObservable; + + private readonly _sessions = new Map(); + + constructor(disposables: DisposableStore) { + super(); + this._onDidChangeSessions = disposables.add(new Emitter()); + this.onDidChangeSessions = this._onDidChangeSessions.event; + this.activeSession = observableValue('test.activeSession', undefined); + } + + override getSession(resource: URI): ISessionData | undefined { + return this._sessions.get(resource.toString()); + } + + addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISessionData { + const changesObs = observableValue('test.changes', + (changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })) + ); + const isArchivedObs = observableValue('test.isArchived', archived); + const sessionData: ISessionData = { + sessionId: `test:${resource.toString()}`, + resource, + changes: changesObs, + isArchived: isArchivedObs, + } as unknown as ISessionData; + this._sessions.set(resource.toString(), sessionData); + return sessionData; + } + + updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void { + const session = this._sessions.get(resource.toString()); + if (session) { + const obs = session.changes as ReturnType>; + obs.set( + (changes ?? []).map(c => ({ modifiedUri: c.modifiedUri ?? c.uri, originalUri: c.originalUri, insertions: c.insertions, deletions: c.deletions })), + undefined + ); + } + } + + removeSession(resource: URI): void { + this._sessions.delete(resource.toString()); + } + + override getSessions(): ISessionData[] { + return [...this._sessions.values()]; + } + + override getGitHubContextForSession(): undefined { + return undefined; + } + + override resolveSessionFileUri(): undefined { + return undefined; + } + + fireSessionsChanged(event?: Partial): void { + this._onDidChangeSessions.fire({ + added: event?.added ?? [], + removed: event?.removed ?? [], + changed: event?.changed ?? [], + }); + } + } + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + + commandService = new MockCommandService(); + instantiationService.stub(ICommandService, commandService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IGitHubService, new class extends mock() { }()); + + sessionsManagement = new MockSessionsManagementService(store); + instantiationService.stub(ISessionsManagementService, sessionsManagement); + + storageService = store.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + service = store.add(instantiationService.createInstance(CodeReviewService)); + session = URI.parse('test://session/1'); + fileA = URI.parse('file:///a.ts'); + fileB = URI.parse('file:///b.ts'); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- getReviewState --- + + test('initial state is idle', () => { + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('getReviewState returns the same observable for the same session', () => { + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session); + assert.strictEqual(obs1, obs2); + }); + + test('getReviewState returns different observables for different sessions', () => { + const session2 = URI.parse('test://session/2'); + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session2); + assert.notStrictEqual(obs1, obs2); + }); + + // --- hasReview --- + + test('hasReview returns false when no review exists', () => { + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + test('hasReview returns false when review is for a different version', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Wait for async command to complete + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + assert.strictEqual(service.hasReview(session, 'v2'), false); + }); + + test('hasReview returns true after successful review', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + }); + + // --- requestReview --- + + test('requestReview transitions to loading state', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + if (state.kind === CodeReviewStateKind.Loading) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); + } + + // Resolve to avoid leaking + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview calls command with correct arguments', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [ + { currentUri: fileA, baseUri: fileB }, + { currentUri: fileB }, + ]); + + await tick(); + + assert.strictEqual(commandService.lastCommandId, 'chat.internal.codeReview.run'); + const args = commandService.lastArgs?.[0] as { files: { currentUri: URI; baseUri?: URI }[] }; + assert.strictEqual(args.files.length, 2); + assert.strictEqual(args.files[0].currentUri.toString(), fileA.toString()); + assert.strictEqual(args.files[0].baseUri?.toString(), fileB.toString()); + assert.strictEqual(args.files[1].currentUri.toString(), fileB.toString()); + assert.strictEqual(args.files[1].baseUri, undefined); + }); + + test('requestReview with success populates comments', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 5, 1), + body: 'Bug found', + kind: 'bug', + severity: 'high', + }, + { + uri: fileB, + range: new Range(10, 1, 15, 1), + body: 'Style issue', + kind: 'style', + severity: 'low', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); + assert.strictEqual(state.comments.length, 2); + assert.strictEqual(state.comments[0].body, 'Bug found'); + assert.strictEqual(state.comments[0].kind, 'bug'); + assert.strictEqual(state.comments[0].severity, 'high'); + assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); + assert.strictEqual(state.comments[1].body, 'Style issue'); + } + }); + + test('requestReview with error transitions to error state', async () => { + commandService.result = { type: 'error', reason: 'Auth failed' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); + assert.strictEqual(state.reason, 'Auth failed'); + } + }); + + test('requestReview with cancelled result transitions to idle', async () => { + commandService.result = { type: 'cancelled' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with undefined result transitions to idle', async () => { + commandService.result = undefined; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with thrown error transitions to error state', async () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + commandService.rejectExecution(new Error('Network error')); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.strictEqual(state.reviewCount, 1); + assert.ok(state.reason.includes('Network error')); + } + }); + + test('requestReview is a no-op when loading for the same version', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Attempt to request again for the same version + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still be loading (not re-triggered) + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview is a no-op when unresolved comments exist for the same version', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + // Attempt to request again + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still have the result + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments.length, 1); + } + }); + + test('requestReview reruns when previous result for the same version had no comments', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + }); + + test('requestReview reruns when all comments for the same version were removed', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const initialState = service.getReviewState(session).get(); + assert.strictEqual(initialState.kind, CodeReviewStateKind.Result); + if (initialState.kind !== CodeReviewStateKind.Result) { + return; + } + + service.removeComment(session, initialState.comments[0].id); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + }); + + test('requestReview is a no-op after five reviews for the same version', async () => { + commandService.result = { type: 'success', comments: [] }; + + for (let i = 0; i < 5; i++) { + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + } + + const stateBefore = service.getReviewState(session).get(); + assert.strictEqual(stateBefore.kind, CodeReviewStateKind.Result); + if (stateBefore.kind === CodeReviewStateKind.Result) { + assert.strictEqual(stateBefore.reviewCount, 5); + } + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const stateAfter = service.getReviewState(session).get(); + assert.strictEqual(stateAfter.kind, CodeReviewStateKind.Result); + if (stateAfter.kind === CodeReviewStateKind.Result) { + assert.strictEqual(stateAfter.reviewCount, 5); + } + }); + + test('requestReview for a new version replaces loading state', async () => { + // Start v1 review — it will complete immediately with empty result + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + // Request v2 — since v1 is a different version, it should proceed + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'v2 comment' }] }; + service.requestReview(session, 'v2', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v2'); + assert.strictEqual(state.comments.length, 1); + assert.strictEqual(state.comments[0].body, 'v2 comment'); + } + + // v1 is no longer valid + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- removeComment --- + + test('removeComment removes a specific comment', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + { uri: fileB, range: new Range(10, 1, 10, 1), body: 'comment3' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + const commentToRemove = state.comments[1]; + service.removeComment(session, commentToRemove.id); + + const newState = service.getReviewState(session).get(); + assert.strictEqual(newState.kind, CodeReviewStateKind.Result); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.comments.length, 2); + assert.strictEqual(newState.comments[0].body, 'comment1'); + assert.strictEqual(newState.comments[1].body, 'comment3'); + } + }); + + test('removeComment is a no-op for unknown comment id', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + service.removeComment(session, 'nonexistent-id'); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments.length, 1); + } + }); + + test('removeComment is a no-op when no review exists', () => { + // Should not throw + service.removeComment(session, 'some-id'); + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('removeComment is a no-op when state is not result', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // State is loading — removeComment should be ignored + service.removeComment(session, 'some-id'); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('removeComment preserves version in result', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + service.removeComment(session, state.comments[0].id); + + const newState = service.getReviewState(session).get(); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.version, 'v1'); + } + }); + + // --- dismissReview --- + + test('dismissReview resets to idle', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('dismissReview while loading resets to idle', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Loading); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + + // Resolve the pending command — should be ignored since dismissed + commandService.resolveExecution({ type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'late' }] }); + }); + + test('dismissReview is a no-op when no data exists', () => { + // Should not throw + service.dismissReview(session); + }); + + test('hasReview returns false after dismissReview', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + service.dismissReview(session); + + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- Isolation between sessions --- + + test('different sessions are independent', async () => { + const session2 = URI.parse('test://session/2'); + + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'session1 comment' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + commandService.result = { + type: 'success', + comments: [{ uri: fileB, range: new Range(2, 1, 2, 1), body: 'session2 comment' }], + }; + service.requestReview(session2, 'v2', [{ currentUri: fileB }]); + await tick(); + + const state1 = service.getReviewState(session).get(); + const state2 = service.getReviewState(session2).get(); + + assert.strictEqual(state1.kind, CodeReviewStateKind.Result); + assert.strictEqual(state2.kind, CodeReviewStateKind.Result); + + if (state1.kind === CodeReviewStateKind.Result && state2.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state1.comments[0].body, 'session1 comment'); + assert.strictEqual(state2.comments[0].body, 'session2 comment'); + } + + // Dismissing session1 doesn't affect session2 + service.dismissReview(session); + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(service.getReviewState(session2).get().kind, CodeReviewStateKind.Result); + }); + + // --- Comment parsing --- + + test('comments with string URIs are parsed correctly', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: 'file:///parsed.ts', + range: new Range(1, 1, 1, 1), + body: 'parsed comment', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), 'file:///parsed.ts'); + } + }); + + test('comments with missing optional fields get defaults', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 1, 1), + // body, kind, severity omitted + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].body, ''); + assert.strictEqual(state.comments[0].kind, ''); + assert.strictEqual(state.comments[0].severity, ''); + assert.strictEqual(state.comments[0].suggestion, undefined); + } + }); + + test('comments normalize VS Code API style ranges', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: { + start: { line: 4, character: 2 }, + end: { line: 6, character: 5 }, + }, + body: 'normalized comment', + suggestion: { + edits: [ + { + range: { + start: { line: 8, character: 1 }, + end: { line: 8, character: 9 }, + }, + oldText: 'let value', + newText: 'const value', + }, + ], + }, + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.deepStrictEqual(state.comments[0].range, new Range(5, 3, 7, 6)); + assert.deepStrictEqual(state.comments[0].suggestion?.edits[0].range, new Range(9, 2, 9, 10)); + } + }); + + test('comments normalize serialized URIs and tuple ranges from API payloads', async () => { + const serializedUri = JSON.parse(JSON.stringify(URI.parse('git:/c%3A/Code/vscode.worktrees/copilot-worktree-2026-03-04T14-44-38/src/vs/sessions/contrib/changesView/test/browser/codeReviewService.test.ts?%7B%22path%22%3A%22c%3A%5C%5CCode%5C%5Cvscode.worktrees%5C%5Ccopilot-worktree-2026-03-04T14-44-38%5C%5Csrc%5C%5Cvs%5C%5Csessions%5C%5Ccontrib%5C%5CchangesView%5C%5Ctest%5C%5Cbrowser%5C%5CcodeReviewService.test.ts%22%2C%22ref%22%3A%22copilot-worktree-2026-03-04T14-44-38%22%7D'))); + + commandService.result = { + type: 'success', + comments: [ + { + uri: serializedUri, + range: [ + { line: 72, character: 2 }, + { line: 72, character: 3 }, + ], + body: 'tuple range comment', + kind: 'bug', + severity: 'medium', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), URI.revive(serializedUri).toString()); + assert.deepStrictEqual(state.comments[0].range, new Range(73, 3, 73, 4)); + } + }); + + test('each comment gets a unique id', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'a' }, + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'b' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.notStrictEqual(state.comments[0].id, state.comments[1].id); + } + }); + + // --- Observable reactivity --- + + test('observable fires on state transitions', async () => { + const states: string[] = []; + const obs = service.getReviewState(session); + + // Collect initial state + states.push(obs.get().kind); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + states.push(obs.get().kind); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + states.push(obs.get().kind); + + service.dismissReview(session); + states.push(obs.get().kind); + + assert.deepStrictEqual(states, [ + CodeReviewStateKind.Idle, + CodeReviewStateKind.Loading, + CodeReviewStateKind.Result, + CodeReviewStateKind.Idle, + ]); + }); + + // --- Storage persistence --- + + test('review results are persisted to storage', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Persisted comment', kind: 'bug', severity: 'high' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE); + assert.ok(raw, 'Storage should contain review data'); + const stored = JSON.parse(raw!); + const reviewData = stored[session.toString()]; + assert.ok(reviewData); + assert.strictEqual(reviewData.version, 'v1'); + assert.strictEqual(reviewData.reviewCount, 1); + assert.strictEqual(reviewData.comments.length, 1); + assert.strictEqual(reviewData.comments[0].body, 'Persisted comment'); + }); + + test('reviews are restored from storage on service creation', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Restored comment', kind: 'bug', severity: 'high' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + // Create a second service with the same storage + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); + assert.strictEqual(state.comments.length, 1); + assert.strictEqual(state.comments[0].body, 'Restored comment'); + assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); + assert.deepStrictEqual(state.comments[0].range, { startLineNumber: 1, startColumn: 1, endLineNumber: 5, endColumn: 1 }); + } + }); + + test('suggestions are persisted and restored correctly', async () => { + commandService.result = { + type: 'success', + comments: [{ + uri: fileA, + range: new Range(1, 1, 5, 1), + body: 'suggestion comment', + suggestion: { + edits: [{ + range: new Range(2, 1, 3, 10), + oldText: 'let x = 1;', + newText: 'const x = 1;', + }], + }, + }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].suggestion?.edits.length, 1); + assert.strictEqual(state.comments[0].suggestion?.edits[0].oldText, 'let x = 1;'); + assert.strictEqual(state.comments[0].suggestion?.edits[0].newText, 'const x = 1;'); + } + }); + + test('removeComment updates storage', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + ], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + service.removeComment(session, state.comments[0].id); + + const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE); + const stored = JSON.parse(raw!); + assert.strictEqual(stored[session.toString()].comments.length, 1); + assert.strictEqual(stored[session.toString()].comments[0].body, 'comment2'); + }); + + test('dismissReview removes session from storage', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'c' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.ok(storageService.get('codeReview.reviews', StorageScope.WORKSPACE)); + + service.dismissReview(session); + + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('corrupted storage is handled gracefully', () => { + storageService.store('codeReview.reviews', 'not-valid-json{{{', StorageScope.WORKSPACE, StorageTarget.MACHINE); + + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + // --- Session lifecycle cleanup --- + + test('archived session reviews are cleaned up', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + const mockSession = sessionsManagement.addSession(session, undefined, true); + sessionsManagement.fireSessionsChanged({ changed: [mockSession] }); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('non-archived session change does not clean up review', async () => { + const changes: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]; + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, version, files); + await tick(); + + const mockSession = sessionsManagement.addSession(session, changes, false); + sessionsManagement.fireSessionsChanged({ changed: [mockSession] }); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + }); + + test('session with changed version has review cleaned up', async () => { + const changes: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]; + sessionsManagement.addSession(session, changes); + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'stale comment' }] }; + service.requestReview(session, version, files); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + const newChanges: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + { uri: fileB, modifiedUri: fileB, insertions: 2, deletions: 0 }, + ]; + sessionsManagement.updateSessionChanges(session, newChanges); + sessionsManagement.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('session that no longer exists has review cleaned up', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'orphaned comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + sessionsManagement.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('session with no changes has review cleaned up', async () => { + sessionsManagement.addSession(session, [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + sessionsManagement.updateSessionChanges(session, undefined); + sessionsManagement.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('session with matching version keeps review intact', async () => { + const changes: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]; + sessionsManagement.addSession(session, changes); + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'valid comment' }] }; + service.requestReview(session, version, files); + await tick(); + + sessionsManagement.fireSessionsChanged(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].body, 'valid comment'); + } + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 4ed1b598aa25e..04711b328a93e 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -8,43 +8,70 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { - 'chat.agentsControl.enabled': true, + 'breadcrumbs.enabled': false, + + 'chat.experimentalSessionsWindowOverride': true, + 'chat.hookFilesLocations': { + '.claude/settings.local.json': false, + '.claude/settings.json': false, + '~/.claude/settings.json': false, + }, 'chat.agent.maxRequests': 1000, - 'chat.restoreLastPanelSession': true, - 'chat.unifiedAgentsBar.enabled': true, + 'chat.customizationsMenu.userStoragePath': '~/.copilot', 'chat.viewSessions.enabled': false, + 'chat.implicitContext.suggestedContext': false, + 'chat.implicitContext.enabled': { 'panel': 'never' }, + 'chat.tools.terminal.enableAutoApprove': true, - 'breadcrumbs.enabled': false, - - 'diffEditor.renderSideBySide': false, 'diffEditor.hideUnchangedRegions.enabled': true, + 'extensions.ignoreRecommendations': true, + 'files.autoSave': 'afterDelay', + 'files.watcherExclude': { + '**/.git/objects/**': true, + '**/.git/subtree-cache/**': true, + '**/node_modules/*/**': true /* TODO@bpasero see if this helps improve perf */, + '**/.hg/store/**': true + }, 'git.autofetch': true, + 'git.branchRandomName.enable': true, 'git.detectWorktrees': false, 'git.showProgress': false, + 'github.copilot.enable': { + 'markdown': true, + 'plaintext': true, + }, 'github.copilot.chat.claudeCode.enabled': true, 'github.copilot.chat.cli.branchSupport.enabled': true, - 'github.copilot.chat.languageContext.typescript.enabled': true, + 'github.copilot.chat.cli.isolationOption.enabled': false, 'github.copilot.chat.cli.mcp.enabled': true, + 'github.copilot.chat.githubMcpServer.enabled': true, + 'github.copilot.chat.languageContext.typescript.enabled': true, 'inlineChat.affordance': 'editor', 'inlineChat.renderMode': 'hover', + 'search.quickOpen.includeHistory': false, + + 'task.notifyWindowOnTaskCompletion': -1, + + 'terminal.integrated.initialHint': false, + + 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', 'workbench.editor.restoreEditors': false, - 'workbench.editor.showTabs': 'single', 'workbench.startupEditor': 'none', 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', - 'workbench.editor.labelFormat': 'short', + 'workbench.editor.modalMinWidth': 600, 'workbench.panel.showLabels': false, + 'workbench.colorTheme': 'VS Code Dark', + 'window.menuStyle': 'custom', 'window.dialogStyle': 'custom', - - 'terminal.integrated.initialHint': false }, donotCache: true, preventExperimentOverride: true, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts new file mode 100644 index 0000000000000..0e187198353a5 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CopilotCLISession } from './copilotChatSessionsProvider.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; + +const FILTER_THRESHOLD = 10; +const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; + +interface IBranchItem { + readonly name: string; +} + +/** + * A self-contained widget for selecting a git branch. + * Observes the active session from {@link ISessionsManagementService} to get + * the current project, opens the git repository via {@link IGitService}, + * and loads branches automatically. + * + * Emits `onDidChange` with the selected branch name. + */ +export class BranchPicker extends Disposable { + + private _selectedBranch: string | undefined; + private _branches: string[] = []; + private _loading = false; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _loadCts = this._register(new MutableDisposable()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + // Watch the active session — load branches when a CopilotCLISession finishes loading + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + if (session instanceof CopilotCLISession) { + const isLoading = session.loading.read(reader); + if (!isLoading && session.gitRepository) { + this._loadBranches(session); + } else if (isLoading) { + // Session is still loading — show disabled state + this._loading = true; + this._branches = []; + this._updateTriggerLabel(); + } else { + // No git repo + this._clearBranches(); + } + } else { + this._clearBranches(); + } + })); + } + + private _loadBranches(session: CopilotCLISession): void { + const repo = session.gitRepository; + if (!repo) { + this._clearBranches(); + return; + } + + this._loadCts.value?.cancel(); + const cts = this._loadCts.value = new CancellationTokenSource(); + + this._loading = true; + this._updateTriggerLabel(); + + repo.getRefs({ pattern: 'refs/heads' }, cts.token).then(refs => { + if (cts.token.isCancellationRequested) { + return; + } + this._branches = refs + .map(r => r.name) + .filter((name): name is string => !!name) + .filter(name => !name.includes(COPILOT_WORKTREE_PATTERN)); + this._loading = false; + this._updateTriggerLabel(); + + // Auto-select the best default branch + const defaultBranch = this._branches.find(b => b === repo.state.get().HEAD?.name) + ?? this._branches.find(b => b === 'main') + ?? this._branches.find(b => b === 'master') + ?? this._branches[0]; + if (defaultBranch) { + this._selectBranch(defaultBranch); + } + }).catch(() => { + if (!cts.token.isCancellationRequested) { + this._branches = []; + this._loading = false; + this._updateTriggerLabel(); + } + }); + } + + private _clearBranches(): void { + this._loadCts.value?.cancel(); + this._branches = []; + this._selectedBranch = undefined; + this._loading = false; + this._updateTriggerLabel(); + } + + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + } + + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || this._branches.length === 0) { + return; + } + + const items = this._buildItems(); + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectBranch(item.name); + }, + onHide: () => { triggerElement.focus(); }, + }; + + const totalActions = items.filter(i => i.kind === ActionListItemKind.Action).length; + + this.actionWidgetService.show( + 'branchPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('branchPicker.ariaLabel', "Branch Picker"), + }, + totalActions > FILTER_THRESHOLD ? { showFilter: true, filterPlaceholder: localize('branchPicker.filter', "Filter branches...") } : undefined, + ); + } + + private _buildItems(): IActionListItem[] { + return this._branches.map(branch => ({ + kind: ActionListItemKind.Action, + label: branch, + group: { title: '', icon: Codicon.gitBranch }, + item: { name: branch, checked: branch === this._selectedBranch || undefined }, + })); + } + + private _selectBranch(branch: string): void { + if (this._selectedBranch !== branch) { + this._selectedBranch = branch; + this._onDidChange.fire(branch); + this._updateTriggerLabel(); + + const session = this.sessionsManagementService.activeSession.get(); + if (!(session instanceof CopilotCLISession)) { + throw new Error('BranchPicker requires a CopilotCLISession'); + } + session.setBranch(branch); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + dom.clearNode(this._triggerElement); + + if (this._loading) { + dom.append(this._triggerElement, renderIcon(Codicon.gitBranch)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = this._selectedBranch ?? localize('branchPicker.select', "Branch"); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + this._slotElement?.classList.toggle('disabled', true); + return; + } + + const isDisabled = this._branches.length === 0; + const label = this._selectedBranch ?? localize('branchPicker.select', "Branch"); + dom.append(this._triggerElement, renderIcon(Codicon.gitBranch)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + this._slotElement?.classList.toggle('disabled', isDisabled); + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts new file mode 100644 index 0000000000000..5e28b9a81a137 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; + +/** + * Registers the {@link CopilotChatSessionsProvider} as a sessions provider. + */ +class DefaultSessionsProviderContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'sessions.defaultSessionsProvider'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + const provider = this._register(instantiationService.createInstance(CopilotChatSessionsProvider)); + this._register(sessionsProvidersService.registerProvider(provider)); + } +} + +registerWorkbenchContribution2(DefaultSessionsProviderContribution.ID, DefaultSessionsProviderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts new file mode 100644 index 0000000000000..3987b6579a8f6 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -0,0 +1,367 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IReader, autorun, observableValue } from '../../../../base/common/observable.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, registerAction2, MenuId, MenuRegistry, isIMenuItem } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { BaseActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IModelPickerDelegate } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; +import { ISessionData } from '../../sessions/common/sessionData.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { CopilotCLISession, COPILOT_PROVIDER_ID, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE } from './copilotChatSessionsProvider.js'; +import { IsolationPicker } from './isolationPicker.js'; +import { BranchPicker } from './branchPicker.js'; +import { ModePicker } from './modePicker.js'; +import { CloudModelPicker } from './modelPicker.js'; +import { NewChatPermissionPicker } from '../../chat/browser/newChatPermissionPicker.js'; + +const ActiveSessionHasGitRepositoryContext = new RawContextKey('activeSessionHasGitRepository', false); +const IsActiveSessionCopilotCLI = ContextKeyExpr.equals('activeSessionType', COPILOT_CLI_SESSION_TYPE); +const IsActiveSessionCopilotCloud = ContextKeyExpr.equals('activeSessionType', COPILOT_CLOUD_SESSION_TYPE); +const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals('activeSessionProviderId', COPILOT_PROVIDER_ID); +const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); +const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider); + +// -- Actions -- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.isolationPicker', + title: localize2('isolationPicker', "Isolation Mode"), + f1: false, + menu: [{ + id: Menus.NewSessionRepositoryConfig, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + IsActiveSessionCopilotChatCLI, + ContextKeyExpr.equals('config.github.copilot.chat.cli.isolationOption.enabled', true), + ), + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.branchPicker', + title: localize2('branchPicker', "Branch"), + f1: false, + precondition: ActiveSessionHasGitRepositoryContext, + menu: [{ + id: Menus.NewSessionRepositoryConfig, + group: 'navigation', + order: 2, + when: IsActiveSessionCopilotChatCLI, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.modePicker', + title: localize2('modePicker', "Mode"), + f1: false, + menu: [{ + id: Menus.NewSessionConfig, + group: 'navigation', + order: 0, + when: IsActiveSessionCopilotChatCLI, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.localModelPicker', + title: localize2('localModelPicker', "Model"), + f1: false, + menu: [{ + id: Menus.NewSessionConfig, + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatCLI, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.cloudModelPicker', + title: localize2('cloudModelPicker', "Model"), + f1: false, + menu: [{ + id: Menus.NewSessionConfig, + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatCloud, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.permissionPicker', + title: localize2('permissionPicker', "Permissions"), + f1: false, + menu: [{ + id: Menus.NewSessionControl, + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatCLI, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +// -- Helper -- + +/** + * Wraps a standalone picker widget as a {@link BaseActionViewItem} + * so it can be rendered by a {@link MenuWorkbenchToolBar}. + */ +class PickerActionViewItem extends BaseActionViewItem { + constructor(private readonly picker: { render(container: HTMLElement): void; dispose(): void }) { + super(undefined, { id: '', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }); + } + + override render(container: HTMLElement): void { + this.picker.render(container); + } + + override dispose(): void { + this.picker.dispose(); + super.dispose(); + } +} + +// -- Action View Item Registrations -- + +class CopilotPickerActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.copilotPickerActionViewItems'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + @ILanguageModelsService languageModelsService: ILanguageModelsService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + this._register(actionViewItemService.register( + Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.isolationPicker', + () => { + const picker = instantiationService.createInstance(IsolationPicker); + return new PickerActionViewItem(picker); + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.branchPicker', + () => { + const picker = instantiationService.createInstance(BranchPicker); + return new PickerActionViewItem(picker); + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionConfig, 'sessions.defaultCopilot.modePicker', + () => { + const picker = instantiationService.createInstance(ModePicker); + return new PickerActionViewItem(picker); + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionConfig, 'sessions.defaultCopilot.localModelPicker', + () => { + const currentModel = observableValue('currentModel', undefined); + const delegate: IModelPickerDelegate = { + currentModel, + setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { + currentModel.set(model, undefined); + const session = sessionsManagementService.activeSession.get(); + if (session) { + const provider = sessionsProvidersService.getProviders().find(p => p.id === session.providerId); + provider?.setModel(session.sessionId, model.identifier); + } + }, + getModels: () => getAvailableModels(languageModelsService), + useGroupedModelPicker: () => true, + showManageModelsAction: () => false, + showUnavailableFeatured: () => false, + showFeatured: () => true, + }; + const pickerOptions: IChatInputPickerOptions = { + hideChevrons: observableValue('hideChevrons', false), + hoverPosition: { hoverPosition: HoverPosition.ABOVE }, + }; + const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; + const modelPicker = instantiationService.createInstance(EnhancedModelPickerActionItem, action, delegate, pickerOptions); + + // Initialize with first available model, or wait for models to load + const initModel = () => { + const models = getAvailableModels(languageModelsService); + modelPicker.setEnabled(models.length > 0); + if (!currentModel.get() && models[0]) { + currentModel.set(models[0], undefined); + } + }; + initModel(); + this._register(languageModelsService.onDidChangeLanguageModels(() => initModel())); + + return modelPicker; + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionConfig, 'sessions.defaultCopilot.cloudModelPicker', + () => { + const picker = instantiationService.createInstance(CloudModelPicker); + return new PickerActionViewItem(picker); + }, + )); + this._register(actionViewItemService.register( + Menus.NewSessionControl, 'sessions.defaultCopilot.permissionPicker', + () => { + const picker = instantiationService.createInstance(NewChatPermissionPicker); + return new PickerActionViewItem(picker); + }, + )); + } +} + +function getAvailableModels(languageModelsService: ILanguageModelsService): ILanguageModelChatMetadataAndIdentifier[] { + return languageModelsService.getLanguageModelIds() + .map(id => { + const metadata = languageModelsService.lookupLanguageModel(id); + return metadata ? { metadata, identifier: id } : undefined; + }) + .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && m.metadata.targetChatSessionType === AgentSessionProviders.Background); +} + +// -- Context Key Contribution -- + +class CopilotActiveSessionContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.copilotActiveSession'; + + constructor( + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const hasRepositoryKey = ActiveSessionHasGitRepositoryContext.bindTo(contextKeyService); + + this._register(autorun((reader: IReader) => { + const session = sessionsManagementService.activeSession.read(reader); + if (session instanceof CopilotCLISession) { + const isLoading = session.loading.read(reader); + hasRepositoryKey.set(!isLoading && !!session.gitRepository); + } else { + hasRepositoryKey.set(false); + } + })); + } +} + +registerWorkbenchContribution2(CopilotPickerActionViewItemContribution.ID, CopilotPickerActionViewItemContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(CopilotActiveSessionContribution.ID, CopilotActiveSessionContribution, WorkbenchPhase.AfterRestored); + +/** + * Bridges extension-contributed context menu actions from {@link MenuId.AgentSessionsContext} + * to {@link SessionItemContextMenuId} for the new sessions view. + * Registers wrapper commands that resolve {@link ISessionData} → {@link IAgentSession} + * and forward to the original command with marshalled context. + */ +class CopilotSessionContextMenuBridge extends Disposable implements IWorkbenchContribution { + static readonly ID = 'copilotChatSessions.contextMenuBridge'; + + private readonly _bridgedIds = new Set(); + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + this._bridgeItems(); + this._register(MenuRegistry.onDidChangeMenu(menuIds => { + if (menuIds.has(MenuId.AgentSessionsContext)) { + this._bridgeItems(); + } + })); + } + + private _bridgeItems(): void { + const items = MenuRegistry.getMenuItems(MenuId.AgentSessionsContext).filter(isIMenuItem); + for (const item of items) { + const commandId = item.command.id; + if (!commandId.startsWith('github.copilot.')) { + continue; + } + if (this._bridgedIds.has(commandId)) { + continue; + } + this._bridgedIds.add(commandId); + + const wrapperId = `sessionsViewPane.bridge.${commandId}`; + this._register(CommandsRegistry.registerCommand(wrapperId, (accessor, sessionData?: ISessionData) => { + if (!sessionData) { + return; + } + const agentSession = this.agentSessionsService.getSession(sessionData.resource); + if (!agentSession) { + return; + } + return this.commandService.executeCommand(commandId, { + session: agentSession, + sessions: [agentSession], + $mid: MarshalledId.AgentSessionContext, + }); + })); + + const providerWhen = ContextKeyExpr.equals('chatSessionProviderId', COPILOT_PROVIDER_ID); + this._register(MenuRegistry.appendMenuItem(SessionItemContextMenuId, { + command: { ...item.command, id: wrapperId }, + group: item.group, + order: item.order, + when: item.when ? ContextKeyExpr.and(providerWhen, item.when) : providerWhen, + })); + } + } +} + +registerWorkbenchContribution2(CopilotSessionContextMenuBridge.ID, CopilotSessionContextMenuBridge, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts new file mode 100644 index 0000000000000..41f12d816bcbf --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -0,0 +1,1097 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { getRepositoryName } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionData, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ISendRequestOptions, ISessionsBrowseAction, ISessionsChangeEvent, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { ISessionOptionGroup } from '../../chat/browser/newSession.js'; +import { IsolationMode } from './isolationPicker.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { isBuiltinChatMode, IChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { ResourceSet } from '../../../../base/common/map.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IGitService, IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { localize } from '../../../../nls.js'; + +const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; + +/** Provider ID for the Copilot Chat Sessions provider. */ +export const COPILOT_PROVIDER_ID = 'default-copilot'; + +/** Session type ID for local Copilot CLI sessions. */ +export const COPILOT_CLI_SESSION_TYPE = AgentSessionProviders.Background; +export const COPILOT_CLOUD_SESSION_TYPE = AgentSessionProviders.Cloud; + +const CopilotCLISessionType: ISessionType = { + id: COPILOT_CLI_SESSION_TYPE, + label: localize('copilotCLI', "Copilot"), + icon: Codicon.copilot, + requiresWorkspaceTrust: true, +}; + +const CopilotCloudSessionType: ISessionType = { + id: COPILOT_CLOUD_SESSION_TYPE, + label: localize('copilotCloud', "Cloud"), + icon: Codicon.cloud, +}; + +const REPOSITORY_OPTION_ID = 'repository'; +const BRANCH_OPTION_ID = 'branch'; +const ISOLATION_OPTION_ID = 'isolation'; +const AGENT_OPTION_ID = 'agent'; + +/** + * Provider-specific observable fields on new Copilot sessions. + * Used by pickers and contributions that need to read/write provider-internal state. + */ +export interface ICopilotNewSessionData extends ISessionData { + readonly permissionLevel: IObservable; + readonly branchObservable: IObservable; + readonly isolationModeObservable: IObservable; +} + +/** + * Local new session for Background agent sessions. + * Implements {@link ISessionData} (session facade) and provides + * pre-send configuration methods for the new-session flow. + */ +export class CopilotCLISession extends Disposable implements ISessionData { + + // -- ISessionData fields -- + + readonly sessionId: string; + readonly providerId: string; + readonly sessionType: string; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + private readonly _branchObservable = observableValue(this, undefined); + readonly branchObservable: IObservable = this._branchObservable; + + private readonly _isolationModeObservable = observableValue(this, undefined); + readonly isolationModeObservable: IObservable = this._isolationModeObservable; + + readonly changes: IObservable = observableValue(this, []); + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; + + private readonly _loading = observableValue(this, true); + readonly loading: IObservable = this._loading; + + readonly isArchived: IObservable = observableValue(this, false); + readonly isRead: IObservable = observableValue(this, true); + readonly description: IObservable = observableValue(this, undefined); + readonly lastTurnEnd: IObservable = observableValue(this, undefined); + readonly pullRequestUri: IObservable = observableValue(this, undefined); + + private _gitRepository: IGitRepository | undefined; + + // -- New session configuration fields -- + + private _repoUri: URI | undefined; + private _isolationMode: IsolationMode; + private _branch: string | undefined; + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + readonly target = AgentSessionProviders.Background; + readonly selectedOptions = new Map(); + + get isolationMode(): IsolationMode { return this._isolationMode; } + get branch(): string | undefined { return this._branch; } + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return this._query; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get gitRepository(): IGitRepository | undefined { return this._gitRepository; } + get disabled(): boolean { + if (!this._repoUri) { + return true; + } + if (this._isolationMode === 'worktree' && !this._branch) { + return true; + } + return false; + } + + constructor( + readonly resource: URI, + readonly sessionWorkspace: ISessionWorkspace, + providerId: string, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IGitService private readonly gitService: IGitService, + ) { + super(); + this.sessionId = `${providerId}:${resource.toString()}`; + this.providerId = providerId; + this.sessionType = AgentSessionProviders.Background; + this.icon = CopilotCLISessionType.icon; + this.createdAt = new Date(); + + const repoUri = sessionWorkspace.repositories[0]?.uri; + if (repoUri) { + this._repoUri = repoUri; + this.setOption(REPOSITORY_OPTION_ID, repoUri.fsPath); + } + + // Set ISessionData workspace observable + this._workspaceData.set(sessionWorkspace, undefined); + + this._isolationMode = 'worktree'; + this.setOption(ISOLATION_OPTION_ID, 'worktree'); + + // Resolve git repository asynchronously + this._resolveGitRepository(); + } + + private async _resolveGitRepository(): Promise { + const repoUri = this.sessionWorkspace.repositories[0]?.uri; + if (repoUri) { + try { + this._gitRepository = await this.gitService.openRepository(repoUri); + } catch { + // No git repository available + } + } + this._loading.set(false, undefined); + } + + setIsolationMode(mode: IsolationMode): void { + if (this._isolationMode !== mode) { + this._isolationMode = mode; + this.setOption(ISOLATION_OPTION_ID, mode); + } + } + + setBranch(branch: string | undefined): void { + if (this._branch !== branch) { + this._branch = branch; + this.setOption(BRANCH_OPTION_ID, branch ?? ''); + } + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + this._modelIdObservable.set(modelId, undefined); + } + + setModeById(modeId: string, modeKind: string): void { + this._modeObservable.set({ id: modeId, kind: modeKind }, undefined); + } + + setPermissionLevel(level: ChatPermissionLevel): void { + this._permissionLevel.set(level, undefined); + } + + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + const modeName = mode?.isBuiltin ? undefined : mode?.name.get(); + this.setOption(AGENT_OPTION_ID, modeName ?? ''); + } + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + this.chatSessionsService.setSessionOption(this.resource, optionId, value); + } +} + +function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + if (group.id === 'models') { + return true; + } + const nameLower = group.name.toLowerCase(); + return nameLower === 'model' || nameLower === 'models'; +} + +function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + return group.id === 'repositories'; +} + +/** + * Remote new session for Cloud agent sessions. + * Implements {@link ISessionData} (session facade) and provides + * pre-send configuration methods for the new-session flow. + */ +export class RemoteNewSession extends Disposable implements ISessionData { + + // -- ISessionData fields -- + + readonly sessionId: string; + readonly providerId: string; + readonly sessionType: string; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + readonly changes: IObservable = observableValue(this, []); + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = observableValue(this, undefined); + + readonly loading: IObservable = observableValue(this, false); + + readonly isArchived: IObservable = observableValue(this, false); + readonly isRead: IObservable = observableValue(this, true); + readonly description: IObservable = observableValue(this, undefined); + readonly lastTurnEnd: IObservable = observableValue(this, undefined); + readonly pullRequestUri: IObservable = observableValue(this, undefined); + + readonly _hasGitRepo = observableValue(this, false); + readonly hasGitRepo: IObservable = this._hasGitRepo; + + // -- New session configuration fields -- + + private _repoUri: URI | undefined; + private _project: ISessionWorkspace | undefined; + private _modelId: string | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + private readonly _onDidChangeOptionGroups = this._register(new Emitter()); + readonly onDidChangeOptionGroups: Event = this._onDidChangeOptionGroups.event; + + readonly selectedOptions = new Map(); + + get project(): ISessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } + get branch(): string | undefined { return undefined; } + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return undefined; } + get query(): string | undefined { return this._query; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } + get disabled(): boolean { + return !this._repoUri && !this.selectedOptions.has('repositories'); + } + + private readonly _whenClauseKeys = new Set(); + + constructor( + readonly resource: URI, + readonly sessionWorkspace: ISessionWorkspace, + readonly target: AgentSessionTarget, + providerId: string, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { + super(); + this.sessionId = `${providerId}:${resource.toString()}`; + this.providerId = providerId; + this.sessionType = target; + this.icon = CopilotCloudSessionType.icon; + this.createdAt = new Date(); + + this._updateWhenClauseKeys(); + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._updateWhenClauseKeys(); + this._onDidChangeOptionGroups.fire(); + })); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { + this._onDidChangeOptionGroups.fire(); + } + })); + + // Set workspace data + this._workspaceData.set(sessionWorkspace, undefined); + this._repoUri = sessionWorkspace.repositories[0]?.uri; + if (this._repoUri) { + const id = this._repoUri.path.substring(1); + this.setOption('repositories', { id, name: id }); + } + + } + + // -- New session configuration methods -- + + setIsolationMode(_mode: IsolationMode): void { + // No-op for remote sessions + } + + setBranch(_branch: string | undefined): void { + // No-op for remote sessions + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + } + + setMode(_mode: IChatMode | undefined): void { + // Intentionally a no-op: remote sessions do not support client-side mode selection. + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value !== 'string') { + this.selectedOptions.set(optionId, value); + } + this.chatSessionsService.setSessionOption(this.resource, optionId, value); + } + + // --- Option group accessors --- + + getModelOptionGroup(): ISessionOptionGroup | undefined { + const groups = this._getOptionGroups(); + if (!groups) { + return undefined; + } + const group = groups.find(g => isModelOptionGroup(g)); + if (!group) { + return undefined; + } + return { group, value: this._getValueForGroup(group) }; + } + + getOtherOptionGroups(): ISessionOptionGroup[] { + const groups = this._getOptionGroups(); + if (!groups) { + return []; + } + return groups + .filter(g => !isModelOptionGroup(g) && !isRepositoriesOptionGroup(g) && this._isOptionGroupVisible(g)) + .map(g => ({ group: g, value: this._getValueForGroup(g) })); + } + + getOptionValue(groupId: string): IChatSessionProviderOptionItem | undefined { + return this.selectedOptions.get(groupId); + } + + setOptionValue(groupId: string, value: IChatSessionProviderOptionItem): void { + this.setOption(groupId, value); + } + + // --- Internals --- + + private _getOptionGroups(): IChatSessionProviderOptionGroup[] | undefined { + return this.chatSessionsService.getOptionGroupsForSessionType(this.target); + } + + private _isOptionGroupVisible(group: IChatSessionProviderOptionGroup): boolean { + if (!group.when) { + return true; + } + const expr = ContextKeyExpr.deserialize(group.when); + return !expr || this.contextKeyService.contextMatchesRules(expr); + } + + private _updateWhenClauseKeys(): void { + this._whenClauseKeys.clear(); + const groups = this._getOptionGroups(); + if (!groups) { + return; + } + for (const group of groups) { + if (group.when) { + const expr = ContextKeyExpr.deserialize(group.when); + if (expr) { + for (const key of expr.keys()) { + this._whenClauseKeys.add(key); + } + } + } + } + } + + private _getValueForGroup(group: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { + const selected = this.selectedOptions.get(group.id); + if (selected) { + return selected; + } + // Check for extension-set session option + const sessionOption = this.chatSessionsService.getSessionOption(this.resource, group.id); + if (sessionOption && typeof sessionOption !== 'string') { + return sessionOption; + } + if (typeof sessionOption === 'string') { + const item = group.items.find(i => i.id === sessionOption.trim()); + if (item) { + return item; + } + } + // Default to first item marked as default, or first item + return group.items.find(i => i.default === true) ?? group.items[0]; + } +} + +/** + * Maps the existing {@link ChatSessionStatus} to the new {@link SessionStatus}. + */ +function toSessionStatus(status: ChatSessionStatus): SessionStatus { + switch (status) { + case ChatSessionStatus.InProgress: + return SessionStatus.InProgress; + case ChatSessionStatus.NeedsInput: + return SessionStatus.NeedsInput; + case ChatSessionStatus.Completed: + return SessionStatus.Completed; + case ChatSessionStatus.Failed: + return SessionStatus.Error; + } +} + +/** + * Adapts an existing {@link IAgentSession} from the chat layer into the new {@link ISessionData} facade. + */ +class AgentSessionAdapter implements ISessionData { + + readonly sessionId: string; + readonly resource: URI; + readonly providerId: string; + readonly sessionType: string; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _workspace: ReturnType>; + readonly workspace: IObservable; + + private readonly _title: ReturnType>; + readonly title: IObservable; + + private readonly _updatedAt: ReturnType>; + readonly updatedAt: IObservable; + + private readonly _status: ReturnType>; + readonly status: IObservable; + + private readonly _changes: ReturnType>; + readonly changes: IObservable; + + readonly modelId: IObservable; + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + readonly loading: IObservable; + + private readonly _isArchived: ReturnType>; + readonly isArchived: IObservable; + + private readonly _isRead: ReturnType>; + readonly isRead: IObservable; + + private readonly _description: ReturnType>; + readonly description: IObservable; + + private readonly _lastTurnEnd: ReturnType>; + readonly lastTurnEnd: IObservable; + + private readonly _pullRequestUri: ReturnType>; + readonly pullRequestUri: IObservable; + + constructor( + session: IAgentSession, + providerId: string, + ) { + this.sessionId = `${providerId}:${session.resource.toString()}`; + this.resource = session.resource; + this.providerId = providerId; + this.sessionType = session.providerType; + this.icon = session.icon; + this.createdAt = new Date(session.timing.created); + this._workspace = observableValue(this, this._buildWorkspace(session)); + this.workspace = this._workspace; + + this._title = observableValue(this, session.label); + this.title = this._title; + + const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created; + this._updatedAt = observableValue(this, new Date(updatedTime)); + this.updatedAt = this._updatedAt; + + this._status = observableValue(this, toSessionStatus(session.status)); + this.status = this._status; + + this._changes = observableValue(this, this._extractChanges(session)); + this.changes = this._changes; + + this.modelId = observableValue(this, undefined); + this.mode = observableValue(this, undefined); + this.loading = observableValue(this, false); + + this._isArchived = observableValue(this, session.isArchived()); + this.isArchived = this._isArchived; + this._isRead = observableValue(this, session.isRead()); + this.isRead = this._isRead; + this._description = observableValue(this, this._extractDescription(session)); + this.description = this._description; + this._lastTurnEnd = observableValue(this, session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined); + this.lastTurnEnd = this._lastTurnEnd; + this._pullRequestUri = observableValue(this, this._extractPullRequestUri(session)); + this.pullRequestUri = this._pullRequestUri; + } + + /** + * Update reactive properties from a refreshed agent session. + */ + update(session: IAgentSession): void { + transaction(tx => { + this._title.set(session.label, tx); + const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created; + this._updatedAt.set(new Date(updatedTime), tx); + this._status.set(toSessionStatus(session.status), tx); + this._changes.set(this._extractChanges(session), tx); + this._isArchived.set(session.isArchived(), tx); + this._isRead.set(session.isRead(), tx); + this._description.set(this._extractDescription(session), tx); + this._lastTurnEnd.set(session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined, tx); + this._pullRequestUri.set(this._extractPullRequestUri(session), tx); + }); + } + + private _extractDescription(session: IAgentSession): string | undefined { + if (!session.description) { + return undefined; + } + return typeof session.description === 'string' ? session.description : session.description.value; + } + + private _extractPullRequestUri(session: IAgentSession): URI | undefined { + const metadata = session.metadata; + if (!metadata) { + return undefined; + } + + const url = metadata.pullRequestUrl as string | undefined; + if (url) { + try { + return URI.parse(url); + } catch { + // fall through + } + } + + // Construct from pullRequestNumber + owner/repo + const prNumber = metadata.pullRequestNumber as number | undefined; + if (typeof prNumber === 'number') { + const owner = metadata.owner as string | undefined; + const name = metadata.name as string | undefined; + if (owner && name) { + return URI.parse(`https://github.com/${owner}/${name}/pull/${prNumber}`); + } + } + + return undefined; + } + + private _extractChanges(session: IAgentSession): readonly IChatSessionFileChange[] { + if (!session.changes) { + return []; + } + if (Array.isArray(session.changes)) { + return session.changes as IChatSessionFileChange[]; + } + // Summary object — create a synthetic entry for total insertions/deletions + const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number }; + if (summary.insertions > 0 || summary.deletions > 0) { + return [{ + modifiedUri: URI.parse('summary://changes'), + insertions: summary.insertions, + deletions: summary.deletions, + }]; + } + return []; + } + + private _buildWorkspace(session: IAgentSession): ISessionWorkspace | undefined { + // Use the same repository name extraction as the old agent sessions view + const label = getRepositoryName(session); + if (!label) { + return undefined; + } + + const [repoUri, worktreeUri, branchName, baseBranchProtected] = this._extractRepositoryFromMetadata(session); + + const repository: ISessionRepository = { + uri: repoUri ?? URI.parse('unknown://'), + workingDirectory: worktreeUri, + detail: branchName, + baseBranchProtected, + }; + + return { + label, + icon: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? Codicon.repo : Codicon.folder, + repositories: [repository], + }; + } + + /** + * Extract repository/worktree information from session metadata. + * Mirrors the logic in sessionsManagementService.getRepositoryFromMetadata(). + */ + private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, boolean | undefined] { + const metadata = session.metadata; + if (!metadata) { + return [undefined, undefined, undefined, undefined]; + } + + if (session.providerType === AgentSessionProviders.Cloud) { + const branch = typeof metadata.branch === 'string' ? metadata.branch : 'HEAD'; + const repositoryUri = URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}` + }); + return [repositoryUri, undefined, undefined, undefined]; + } + + // Background/CLI sessions: check workingDirectoryPath first + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; + if (workingDirectoryPath) { + return [URI.file(workingDirectoryPath), undefined, undefined, undefined]; + } + + // Fall back to repositoryPath + worktreePath + const repositoryPath = metadata?.repositoryPath as string | undefined; + const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; + + const worktreePath = metadata?.worktreePath as string | undefined; + const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + + const worktreeBranchName = metadata?.branchName as string | undefined; + const worktreeBaseBranchProtected = metadata?.baseBranchProtected as boolean | undefined; + + return [ + URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, + URI.isUri(worktreePathUri) ? worktreePathUri : undefined, + worktreeBranchName, + worktreeBaseBranchProtected, + ]; + } +} + +/** + * Default sessions provider for Copilot CLI and Cloud session types. + * Wraps the existing session infrastructure into the extensible provider model. + */ +export class CopilotChatSessionsProvider extends Disposable implements ISessionsProvider { + + readonly id = COPILOT_PROVIDER_ID; + readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); + readonly icon = Codicon.copilot; + readonly sessionTypes: readonly ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + /** Cache of adapted sessions, keyed by resource URI string. */ + private readonly _sessionCache = new Map(); + + readonly browseActions: readonly ISessionsBrowseAction[]; + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatService private readonly chatService: IChatService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @ICommandService private readonly commandService: ICommandService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, + ) { + super(); + + this.browseActions = [ + { + label: 'Browse Folders...', + icon: Codicon.folderOpened, + providerId: this.id, + execute: () => this._browseForFolder(), + }, + { + label: 'Browse Repositories...', + icon: Codicon.repo, + providerId: this.id, + execute: () => this._browseForRepo(), + }, + ]; + + // Forward session changes from the underlying model + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._refreshSessionCache(); + })); + } + + // -- Workspaces -- + + // -- Sessions -- + + getSessionTypes(session: ISessionData): ISessionType[] { + if (session instanceof CopilotCLISession) { + return [CopilotCLISessionType]; + } + if (session instanceof RemoteNewSession) { + return [CopilotCloudSessionType]; + } + return []; + } + + getSessions(): ISessionData[] { + this._ensureSessionCache(); + return Array.from(this._sessionCache.values()); + } + + // -- Session Lifecycle -- + + private _currentNewSession: (CopilotCLISession | RemoteNewSession) | undefined; + + createNewSession(workspace: ISessionWorkspace): ISessionData { + const workspaceUri = workspace.repositories[0]?.uri; + if (!workspaceUri) { + throw new Error('Workspace has no repository URI'); + } + + if (this._currentNewSession) { + this._currentNewSession.dispose(); + this._currentNewSession = undefined; + } + + if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id); + this._currentNewSession = session; + return session; + } + + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id); + this._currentNewSession = session; + return session; + } + + setSessionType(sessionId: string, type: ISessionType): ISessionData { + throw new Error('Session type cannot be changed'); + } + + setModel(sessionId: string, modelId: string): void { + if (this._currentNewSession?.sessionId === sessionId) { + this._currentNewSession.setModelId(modelId); + } + } + + // -- Session Actions -- + + async archiveSession(sessionId: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setArchived(true); + } + } + + async unarchiveSession(sessionId: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setArchived(false); + } + } + + async deleteSession(sessionId: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + await this.chatService.removeHistoryEntry(agentSession.resource); + this._refreshSessionCache(); + } + } + + async renameSession(sessionId: string, title: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + this.chatService.setChatSessionTitle(agentSession.resource, title); + } + } + + setRead(sessionId: string, read: boolean): void { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setRead(read); + } + } + + // -- Send -- + + async sendRequest(sessionId: string, options: ISendRequestOptions): Promise { + const session = this._currentNewSession; + if (!session || session.sessionId !== sessionId) { + throw new Error(`Session '${sessionId}' not found or not a new session`); + } + + const { query, attachedContext } = options; + + const contribution = this.chatSessionsService.getChatSessionContribution(session.target); + + // Resolve mode + const modeKind = session.chatMode?.kind ?? ChatModeKind.Agent; + const modeIsBuiltin = session.chatMode ? isBuiltinChatMode(session.chatMode) : true; + const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = modeIsBuiltin ? modeKind : 'custom'; + + const rawModeInstructions = session.chatMode?.modeInstructions?.get(); + const modeInstructions = rawModeInstructions ? { + name: session.chatMode!.name.get(), + content: rawModeInstructions.content, + toolReferences: this.toolsService.toToolReferences(rawModeInstructions.toolReferences), + metadata: rawModeInstructions.metadata, + } : undefined; + + const permissionLevel = session.permissionLevel.get(); + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: session.selectedModelId, + modeInfo: { + kind: modeKind, + isBuiltin: modeIsBuiltin, + modeInstructions, + modeId, + applyCodeBlockSuggestionId: undefined, + permissionLevel, + }, + agentIdSilent: contribution?.type, + attachedContext, + }; + + // Open chat widget and set permission level + await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); + const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[DefaultCopilotProvider] Failed to open chat widget'); + } + + if (permissionLevel) { + chatWidget.input.setPermissionLevel(permissionLevel); + } + + // Load session model with selected options + const modelRef = await this.chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None); + if (modelRef) { + const model = modelRef.object; + if (session.selectedModelId) { + const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId); + if (languageModel) { + model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } }); + } + } + if (session.chatMode) { + model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } }); + } + if (session.selectedOptions.size > 0) { + const contributedSession = model.contributedChatSession; + if (contributedSession) { + model.setContributedChatSession({ ...contributedSession, initialSessionOptions: session.selectedOptions }); + } + } + modelRef.dispose(); + } + + // Send request + const existingResources = new ResourceSet(this.agentSessionsService.model.sessions.map(s => s.resource)); + const result = await this.chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); + } + + // Wait for the agent session to appear + const newAgentSession = await this._waitForNewAgentSession(session.target, existingResources); + this._currentNewSession = undefined; + if (!newAgentSession) { + throw new Error('[DefaultCopilotProvider] Failed to create agent session'); + } + return this._wrapAgentSession(newAgentSession); + } + + private async _waitForNewAgentSession(target: AgentSessionTarget, existingSessions: ResourceSet): Promise { + const found = this.agentSessionsService.model.sessions.find(s => s.providerType === target && !existingSessions.has(s.resource)); + if (found) { + return found; + } + return new Promise(resolve => { + const listener = this.agentSessionsService.model.onDidChangeSessions(() => { + const s = this.agentSessionsService.model.sessions.find(s => s.providerType === target && !existingSessions.has(s.resource)); + if (s) { + listener.dispose(); + resolve(s); + } + }); + }); + } + + private _wrapAgentSession(agentSession: IAgentSession): ISessionData { + const adapter = new AgentSessionAdapter(agentSession, this.id); + this._sessionCache.set(agentSession.resource.toString(), adapter); + return adapter; + } + + // -- Private -- + + private async _browseForFolder(): Promise { + const result = await this.fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + if (result?.length) { + const uri = result[0]; + return { + label: this._labelFromUri(uri), + icon: this._iconFromUri(uri), + repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + }; + } + return undefined; + } + + private async _browseForRepo(): Promise { + const repoId = await this.commandService.executeCommand(OPEN_REPO_COMMAND); + if (repoId) { + const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` }); + return { + label: this._labelFromUri(uri), + icon: this._iconFromUri(uri), + repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + }; + } + return undefined; + } + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + return { + label: this._labelFromUri(repositoryUri), + icon: this._iconFromUri(repositoryUri), + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + }; + } + + private _labelFromUri(uri: URI): string { + if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + return uri.path.substring(1).replace(/\/HEAD$/, ''); + } + return basename(uri); + } + + private _iconFromUri(uri: URI): ThemeIcon { + if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + return Codicon.repo; + } + return Codicon.folder; + } + + private _isOwnedSession(session: IAgentSession): boolean { + return session.providerType === AgentSessionProviders.Background + || session.providerType === AgentSessionProviders.Cloud; + } + + private _ensureSessionCache(): void { + if (this._sessionCache.size > 0) { + return; + } + this._refreshSessionCache(); + } + + private _refreshSessionCache(): void { + const currentSessions = this.agentSessionsService.model.sessions.filter(s => this._isOwnedSession(s)); + const currentKeys = new Set(); + const added: ISessionData[] = []; + const changed: ISessionData[] = []; + + for (const session of currentSessions) { + const key = session.resource.toString(); + currentKeys.add(key); + + const existing = this._sessionCache.get(key); + if (existing) { + existing.update(session); + changed.push(existing); + } else { + const adapter = new AgentSessionAdapter(session, this.id); + this._sessionCache.set(key, adapter); + added.push(adapter); + } + } + + const removed: ISessionData[] = []; + for (const [key, adapter] of this._sessionCache) { + if (!currentKeys.has(key)) { + this._sessionCache.delete(key); + removed.push(adapter); + } + } + + if (added.length > 0 || removed.length > 0 || changed.length > 0) { + this._onDidChangeSessions.fire({ added, removed, changed }); + } + } + + private _findAgentSession(sessionId: string): IAgentSession | undefined { + const adapter = this._sessionCache.get(this._localIdFromSessionId(sessionId)); + if (!adapter) { + return undefined; + } + return this.agentSessionsService.getSession(adapter.resource); + } + + private _localIdFromSessionId(sessionId: string): string { + const prefix = `${this.id}:`; + return sessionId.startsWith(prefix) ? sessionId.substring(prefix.length) : sessionId; + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts new file mode 100644 index 0000000000000..eda0d770c62c0 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CopilotCLISession } from './copilotChatSessionsProvider.js'; + +export type IsolationMode = 'worktree' | 'workspace'; + +/** + * A self-contained widget for selecting the isolation mode. + * + * Options: + * - **Worktree** (`worktree`) — run in a git worktree + * - **Folder** (`workspace`) — run directly in the folder + * + * Only visible when isolation option is enabled, project has a git repo, + * and the target is CLI. + * + * Emits `onDidChange` with the selected `IsolationMode` when the user picks an option. + */ +export class IsolationPicker extends Disposable { + + private _isolationMode: IsolationMode = 'worktree'; + private _hasGitRepo = false; + private _isolationOptionEnabled: boolean; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + this._isolationOptionEnabled = this.configurationService.getValue('github.copilot.chat.cli.isolationOption.enabled') !== false; + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('github.copilot.chat.cli.isolationOption.enabled')) { + this._isolationOptionEnabled = this.configurationService.getValue('github.copilot.chat.cli.isolationOption.enabled') !== false; + if (!this._isolationOptionEnabled) { + this._setMode('worktree'); + } + this._updateTriggerLabel(); + } + })); + + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + if (session instanceof CopilotCLISession) { + const isLoading = session.loading.read(reader); + this.setHasGitRepo(!isLoading && !!session.gitRepository); + } else { + this.setHasGitRepo(false); + } + })); + } + + private setHasGitRepo(hasRepo: boolean): void { + this._hasGitRepo = hasRepo; + if (!hasRepo) { + this._setMode('workspace'); + } else { + this._setMode('worktree'); + } + this._updateTriggerLabel(); + } + + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + this._slotElement = slot; + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + if (!this._hasGitRepo || !this._isolationOptionEnabled) { + return; + } + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + label: localize('isolationMode.worktree', "Worktree"), + group: { title: '', icon: Codicon.worktree }, + item: 'worktree', + }, + { + kind: ActionListItemKind.Action, + label: localize('isolationMode.folder', "Folder"), + group: { title: '', icon: Codicon.folder }, + item: 'workspace', + }, + ]; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (mode) => { + this.actionWidgetService.hide(); + this._setMode(mode); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'isolationPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('isolationPicker.ariaLabel', "Isolation Mode"), + }, + ); + } + + private _setMode(mode: IsolationMode): void { + if (this._isolationMode !== mode) { + this._isolationMode = mode; + this._updateTriggerLabel(); + this._onDidChange.fire(mode); + + const session = this.sessionsManagementService.activeSession.get(); + if (!(session instanceof CopilotCLISession)) { + throw new Error('IsolationPicker requires a CopilotCLISession'); + } + session.setIsolationMode(mode); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + let modeIcon; + let modeLabel: string; + + switch (this._isolationMode) { + case 'workspace': + modeIcon = Codicon.folder; + modeLabel = localize('isolationMode.folder', "Folder"); + break; + case 'worktree': + default: + modeIcon = Codicon.worktree; + modeLabel = localize('isolationMode.worktree', "Worktree"); + break; + } + + dom.append(this._triggerElement, renderIcon(modeIcon)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = modeLabel; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + const isDisabled = !this._hasGitRepo; + this._slotElement?.classList.toggle('disabled', isDisabled); + this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); + this._triggerElement.tabIndex = isDisabled ? -1 : 0; + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts new file mode 100644 index 0000000000000..2dd210c9d03ac --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ChatMode, IChatMode, IChatModeService } from '../../../../workbench/contrib/chat/common/chatModes.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementCommands } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CopilotCLISession } from './copilotChatSessionsProvider.js'; + +interface IModePickerItem { + readonly kind: 'mode'; + readonly mode: IChatMode; +} + +interface IConfigurePickerItem { + readonly kind: 'configure'; +} + +type ModePickerItem = IModePickerItem | IConfigurePickerItem; + +/** + * A self-contained widget for selecting a chat mode (Agent, custom agents) + * for local/Background sessions. Shows only modes whose target matches + * the Background session type's customAgentTarget. + */ +export class ModePicker extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _triggerElement: HTMLElement | undefined; + private _slotElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + private _selectedMode: IChatMode = ChatMode.Agent; + + get selectedMode(): IChatMode { + return this._selectedMode; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IChatModeService private readonly chatModeService: IChatModeService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ICommandService private readonly commandService: ICommandService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + this._register(this.chatModeService.onDidChangeChatModes(() => { + // Refresh the trigger label when available chat modes change + if (this._triggerElement) { + this._updateTriggerLabel(); + } + })); + } + + /** + * Resets the selected mode back to the default Agent mode. + */ + reset(): void { + this._selectedMode = ChatMode.Agent; + this._updateTriggerLabel(); + } + + /** + * Renders the mode picker trigger button into the given container. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + trigger.setAttribute('aria-label', localize('sessions.modePicker.ariaLabel', "Select chat mode")); + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + private _getAvailableModes(): IChatMode[] { + const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(AgentSessionProviders.Background); + const effectiveTarget = customAgentTarget && customAgentTarget !== Target.Undefined ? customAgentTarget : Target.GitHubCopilot; + const modes = this.chatModeService.getModes(); + + // Always include the default Agent mode + const result: IChatMode[] = [ChatMode.Agent]; + + // Add custom modes matching the target and visible to users + for (const mode of modes.custom) { + const target = mode.target.get(); + if (target === effectiveTarget || target === Target.Undefined) { + const visibility = mode.visibility?.get(); + if (visibility && !visibility.userInvocable) { + continue; + } + result.push(mode); + } + } + + return result; + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const modes = this._getAvailableModes(); + + const items = this._buildItems(modes); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.kind === 'mode') { + this._selectMode(item.mode); + } else { + this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'localModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modePicker.ariaLabel', "Mode Picker"), + }, + ); + } + + private _buildItems(modes: IChatMode[]): IActionListItem[] { + const items: IActionListItem[] = []; + + // Default Agent mode + const agentMode = modes[0]; + items.push({ + kind: ActionListItemKind.Action, + label: agentMode.label.get(), + group: { title: '', icon: this._selectedMode.id === agentMode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode: agentMode }, + }); + + // Custom modes (with separator if any exist) + const customModes = modes.slice(1); + if (customModes.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + for (const mode of customModes) { + items.push({ + kind: ActionListItemKind.Action, + label: mode.label.get(), + group: { title: '', icon: this._selectedMode.id === mode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode }, + }); + } + } + + // Configure Custom Agents action + items.push({ kind: ActionListItemKind.Separator, label: '' }); + items.push({ + kind: ActionListItemKind.Action, + label: localize('configureCustomAgents', "Configure Custom Agents..."), + group: { title: '', icon: Codicon.blank }, + item: { kind: 'configure' }, + }); + + return items; + } + + private _selectMode(mode: IChatMode): void { + this._selectedMode = mode; + this._updateTriggerLabel(); + this._onDidChange.fire(mode); + + const session = this.sessionsManagementService.activeSession.get(); + if (session instanceof CopilotCLISession) { + session.setMode(mode); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + const icon = this._selectedMode.icon.get(); + if (icon) { + dom.append(this._triggerElement, renderIcon(icon)); + } + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = this._selectedMode.label.get(); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + const modes = this._getAvailableModes(); + this._slotElement?.classList.toggle('disabled', modes.length <= 1); + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts new file mode 100644 index 0000000000000..798cb575ac433 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { RemoteNewSession } from './copilotChatSessionsProvider.js'; + +const FILTER_THRESHOLD = 10; + +interface IModelItem { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +/** + * A self-contained widget for selecting a model in cloud sessions. + * Reads the model option group from the {@link RemoteNewSession} and + * renders an action list dropdown with the available models. + */ +export class CloudModelPicker extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _triggerElement: HTMLElement | undefined; + private _slotElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _sessionDisposables = this._register(new DisposableStore()); + + private _session: RemoteNewSession | undefined; + private _selectedModel: IModelItem | undefined; + private _models: IModelItem[] = []; + + get selectedModel(): IModelItem | undefined { + return this._selectedModel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + ) { + super(); + + this._register(autorun(reader => { + const session = sessionsManagementService.activeSession.read(reader); + if (session instanceof RemoteNewSession) { + this._setSession(session); + } + })); + + // Also listen directly for option group changes from the extension host, + // in case they arrive before the RemoteNewSession relays the event. + this._register(chatSessionsService.onDidChangeOptionGroups(() => { + if (this._session) { + this._loadModels(this._session); + } + })); + } + + private _setSession(session: RemoteNewSession): void { + this._session = session; + this._sessionDisposables.clear(); + this._loadModels(session); + + // Sync selected model to the new session + if (this._selectedModel) { + session.setModelId(this._selectedModel.id); + session.setOptionValue('models', { id: this._selectedModel.id, name: this._selectedModel.name }); + } + + // Re-load models when option groups change + this._sessionDisposables.add(session.onDidChangeOptionGroups(() => { + this._loadModels(session); + })); + } + + /** + * Renders the model picker trigger button into the given container. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + private _loadModels(session: RemoteNewSession): void { + const modelOption = session.getModelOptionGroup(); + if (modelOption?.group.items.length) { + this._models = modelOption.group.items.map(item => ({ + id: item.id, + name: item.name, + description: item.description, + })); + + // Select the session's current value, or the default, or the first + if (!this._selectedModel || !this._models.some(m => m.id === this._selectedModel!.id)) { + const value = modelOption.value; + this._selectedModel = value + ? { id: value.id, name: value.name, description: value.description } + : this._models[0]; + } + } else { + this._models = []; + } + this._updateTriggerLabel(); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || this._models.length === 0) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectModel(item); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'remoteModelPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modelPicker.ariaLabel', "Model Picker"), + }, + showFilter ? { showFilter: true, filterPlaceholder: localize('modelPicker.filter', "Filter models...") } : undefined, + ); + } + + private _buildItems(): IActionListItem[] { + return this._models.map(model => ({ + kind: ActionListItemKind.Action, + label: model.name, + group: { title: '', icon: this._selectedModel?.id === model.id ? Codicon.check : Codicon.blank }, + item: model, + })); + } + + private _selectModel(item: IModelItem): void { + this._selectedModel = item; + this._updateTriggerLabel(); + + if (this._session) { + this._session.setModelId(item.id); + this._session.setOptionValue('models', { id: item.id, name: item.name }); + } + this._onDidChange.fire({ id: item.id, name: item.name, description: item.description }); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const label = this._selectedModel?.name ?? localize('modelPicker.auto', "Auto"); + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._slotElement?.classList.toggle('disabled', this._models.length === 0); + this._triggerElement.setAttribute('aria-disabled', String(this._models.length === 0)); + } +} diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts index 1d67b91d1b4b3..94d04b0522156 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -7,7 +7,8 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { GitHubFileSystemProvider, GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; +import { GitHubFileSystemProvider } from './githubFileSystemProvider.js'; // --- View registration is currently disabled in favor of the "Add Context" picker. // The Files view will be re-enabled once we finalize the sessions auxiliary bar layout. diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts index 0ef758704325e..07c84810bd456 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -42,8 +42,9 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { ISessionsManagementService, IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from './githubFileSystemProvider.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionData, GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; +import { getGitHubRemoteFileDisplayName } from './githubFileSystemProvider.js'; import { basename } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -235,24 +236,27 @@ export class FileTreeViewPane extends ViewPane { /** * Determines the root URI for the file tree based on the active session type. - * Tries multiple data sources: IActiveSessionItem fields, agent session model metadata, + * Tries multiple data sources: ISessionData workspace, agent session model metadata, * and file change URIs as a last resort. */ - private resolveTreeRoot(activeSession: IActiveSessionItem | undefined): URI | undefined { + private resolveTreeRoot(activeSession: ISessionData | undefined): URI | undefined { if (!activeSession) { return undefined; } const sessionType = getChatSessionType(activeSession.resource); - - // 1. Try the direct worktree/repository fields from IActiveSessionItem - if (activeSession.worktree) { - this.logService.info(`[FileTreeView] Using worktree: ${activeSession.worktree.toString()}`); - return activeSession.worktree; + const repo = activeSession.workspace.get()?.repositories[0]; + const worktree = repo?.workingDirectory; + const repository = repo?.uri; + + // 1. Try the direct worktree/repository fields from workspace + if (worktree) { + this.logService.info(`[FileTreeView] Using worktree: ${worktree.toString()}`); + return worktree; } - if (activeSession.repository && activeSession.repository.scheme === 'file') { - this.logService.info(`[FileTreeView] Using repository: ${activeSession.repository.toString()}`); - return activeSession.repository; + if (repository && repository.scheme === 'file') { + this.logService.info(`[FileTreeView] Using repository: ${repository.toString()}`); + return repository; } // 2. Query the agent session model directly for metadata @@ -294,8 +298,8 @@ export class FileTreeViewPane extends ViewPane { } // 4. Try to parse the repository URI as a GitHub URL - if (activeSession.repository) { - const repoStr = activeSession.repository.toString(); + if (repository) { + const repoStr = repository.toString(); const parsed = this.parseGitHubUrl(repoStr); if (parsed) { this.logService.info(`[FileTreeView] Parsed repository URI as GitHub: ${parsed.owner}/${parsed.repo}`); @@ -315,6 +319,9 @@ export class FileTreeViewPane extends ViewPane { * Extracts a github-remote-file:// URI from session metadata, trying various known fields. */ private extractRepoUriFromMetadata(metadata: { readonly [key: string]: unknown }): URI | undefined { + const branch = typeof metadata.branch === 'string' ? metadata.branch : 'HEAD'; + const encodedRef = encodeURIComponent(branch); + // repositoryNwo: "owner/repo" const repositoryNwo = metadata.repositoryNwo as string | undefined; if (repositoryNwo && repositoryNwo.includes('/')) { @@ -322,7 +329,7 @@ export class FileTreeViewPane extends ViewPane { return URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', - path: `/${repositoryNwo}/HEAD`, + path: `/${repositoryNwo}/${encodedRef}`, }); } @@ -335,7 +342,7 @@ export class FileTreeViewPane extends ViewPane { return URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/HEAD`, + path: `/${parsed.owner}/${parsed.repo}/${encodedRef}`, }); } } @@ -349,7 +356,7 @@ export class FileTreeViewPane extends ViewPane { return URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', - path: `/${repository}/HEAD`, + path: `/${repository}/${encodedRef}`, }); } const parsed = this.parseGitHubUrl(repository); @@ -358,7 +365,7 @@ export class FileTreeViewPane extends ViewPane { return URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/HEAD`, + path: `/${parsed.owner}/${parsed.repo}/${encodedRef}`, }); } } @@ -514,7 +521,7 @@ export class FileTreeViewPane extends ViewPane { if (this.tree && rootUri && !isEqual(rootUri, lastRootUri)) { lastRootUri = rootUri; - this.updateTitle(basename(rootUri.path) || rootUri.toString()); + this.updateTitle(this.getTreeTitle(rootUri)); this.treeInputDisposable.clear(); this.tree.setInput(rootUri).then(() => { this.layoutTree(); @@ -525,6 +532,10 @@ export class FileTreeViewPane extends ViewPane { })); } + private getTreeTitle(rootUri: URI): string { + return getGitHubRemoteFileDisplayName(rootUri) ?? (basename(rootUri.path) || rootUri.toString()); + } + private layoutTree(): void { if (!this.tree) { return; diff --git a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts index 289911e399578..41966d8b595a1 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -11,8 +11,29 @@ import { IRequestService, asJson } from '../../../../platform/request/common/req import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; -export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; +/** + * Derives a display name from a github-remote-file URI. + * Returns "repo (branch)" or just "repo" when on HEAD. + */ +export function getGitHubRemoteFileDisplayName(uri: URI): string | undefined { + if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { + return undefined; + } + const parts = uri.path.split('/').filter(Boolean); + // path = /{owner}/{repo}/{ref}/... + if (parts.length >= 3) { + const [, repo, ref] = parts; + const decodedRepo = decodeURIComponent(repo); + const decodedRef = decodeURIComponent(ref); + if (decodedRef === 'HEAD') { + return decodedRepo; + } + return `${decodedRepo} (${decodedRef})`; + } + return undefined; +} /** * GitHub REST API response for the Trees endpoint. @@ -67,9 +88,18 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP /** Cache keyed by "owner/repo/ref" */ private readonly treeCache = new Map(); + /** Negative cache for refs that returned 404, keyed by "owner/repo/ref" */ + private readonly notFoundCache = new Map(); + + /** In-flight fetch promises keyed by "owner/repo/ref" to deduplicate concurrent requests */ + private readonly pendingFetches = new Map>(); + /** Cache TTL - 5 minutes */ private static readonly CACHE_TTL_MS = 5 * 60 * 1000; + /** Negative cache TTL - 1 minute */ + private static readonly NOT_FOUND_CACHE_TTL_MS = 60 * 1000; + constructor( @IRequestService private readonly requestService: IRequestService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @@ -92,10 +122,10 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP throw createFileSystemProviderError('Invalid github-remote-file URI: expected /{owner}/{repo}/{ref}/...', FileSystemProviderErrorCode.FileNotFound); } - const owner = parts[0]; - const repo = parts[1]; - const ref = parts[2]; - const path = parts.slice(3).join('/'); + const owner = decodeURIComponent(parts[0]); + const repo = decodeURIComponent(parts[1]); + const ref = decodeURIComponent(parts[2]); + const path = parts.slice(3).map(decodeURIComponent).join('/'); return { owner, repo, ref, path }; } @@ -107,23 +137,45 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP // --- GitHub API private async getAuthToken(): Promise { - const sessions = await this.authenticationService.getSessions('github', ['repo']); - if (sessions.length > 0) { - return sessions[0].accessToken; + let sessions = await this.authenticationService.getSessions('github', [], { silent: true }); + if (!sessions || sessions.length === 0) { + sessions = await this.authenticationService.getSessions('github', [], { createIfNone: true }); } - - // Try to create a session if none exists - const session = await this.authenticationService.createSession('github', ['repo']); - return session.accessToken; + if (!sessions || sessions.length === 0) { + throw createFileSystemProviderError('No GitHub authentication sessions available', FileSystemProviderErrorCode.Unavailable); + } + return sessions[0].accessToken ?? ''; } - private async fetchTree(owner: string, repo: string, ref: string): Promise { + private fetchTree(owner: string, repo: string, ref: string): Promise { const cacheKey = this.getCacheKey(owner, repo, ref); + + // Check positive cache const cached = this.treeCache.get(cacheKey); if (cached && (Date.now() - cached.fetchedAt) < GitHubFileSystemProvider.CACHE_TTL_MS) { - return cached; + return Promise.resolve(cached); + } + + // Check negative cache (recently returned 404) + const notFoundAt = this.notFoundCache.get(cacheKey); + if (notFoundAt !== undefined && (Date.now() - notFoundAt) < GitHubFileSystemProvider.NOT_FOUND_CACHE_TTL_MS) { + return Promise.reject(createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound)); } + // Deduplicate concurrent requests for the same tree + const pending = this.pendingFetches.get(cacheKey); + if (pending) { + return pending; + } + + const promise = this.doFetchTree(owner, repo, ref, cacheKey).finally(() => { + this.pendingFetches.delete(cacheKey); + }); + this.pendingFetches.set(cacheKey, promise); + return promise; + } + + private async doFetchTree(owner: string, repo: string, ref: string, cacheKey: string): Promise { this.logService.info(`[SessionRepoFS] Fetching tree for ${owner}/${repo}@${ref}`); const token = await this.getAuthToken(); @@ -136,9 +188,17 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'VSCode-SessionRepoFS', }, + callSite: 'githubFileSystemProvider.fetchTree' }, CancellationToken.None); + // Cache 404s so we don't keep re-fetching missing trees + if (response.res.statusCode === 404) { + this.notFoundCache.set(cacheKey, Date.now()); + throw createFileSystemProviderError(`Tree not found for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.FileNotFound); + } + const data = await asJson(response); + if (!data) { throw createFileSystemProviderError(`Failed to fetch tree for ${owner}/${repo}@${ref}`, FileSystemProviderErrorCode.Unavailable); } @@ -239,6 +299,7 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'VSCode-SessionRepoFS', }, + callSite: 'githubFileSystemProvider.readFile' }, CancellationToken.None); const data = await asJson<{ content: string; encoding: string }>(response); @@ -283,11 +344,15 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP // --- Cache management invalidateCache(owner: string, repo: string, ref: string): void { - this.treeCache.delete(this.getCacheKey(owner, repo, ref)); + const cacheKey = this.getCacheKey(owner, repo, ref); + this.treeCache.delete(cacheKey); + this.notFoundCache.delete(cacheKey); } override dispose(): void { this.treeCache.clear(); + this.notFoundCache.clear(); + this.pendingFetches.clear(); super.dispose(); } } diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts new file mode 100644 index 0000000000000..6d9f6fa696a4e --- /dev/null +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; +import { ExplorerView } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; + +const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; +const SESSIONS_FILES_VIEW_ID = 'sessions.files.explorer'; + +const filesViewIcon = registerIcon('sessions-files-view-icon', Codicon.files, localize2('sessionsFilesViewIcon', 'View icon of the files view in the sessions window.').value); + +class RegisterFilesViewContribution implements IWorkbenchContribution { + + static readonly ID = 'sessions.registerFilesView'; + + constructor() { + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + + // Register a new Files view container in the auxiliary bar for the sessions window + const filesViewContainer = viewContainerRegistry.registerViewContainer({ + id: SESSIONS_FILES_CONTAINER_ID, + title: localize2('files', "Files"), + icon: filesViewIcon, + order: 11, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_FILES_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: SESSIONS_FILES_CONTAINER_ID, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true }); + + // Re-register the explorer view inside the new Files container + viewsRegistry.registerViews([{ + id: SESSIONS_FILES_VIEW_ID, + name: localize2('files', "Files"), + containerIcon: filesViewIcon, + ctorDescriptor: new SyncDescriptor(ExplorerView), + canToggleVisibility: true, + canMoveView: false, + when: WorkspaceFolderCountContext.notEqualsTo('0'), + windowVisibility: WindowVisibility.Sessions, + }], filesViewContainer); + } +} + +registerWorkbenchContribution2(RegisterFilesViewContribution.ID, RegisterFilesViewContribution, WorkbenchPhase.AfterRestored); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.files.action.collapseExplorerFolders', + title: localize2('collapseExplorerFolders', "Collapse Folders in Explorer"), + icon: Codicon.collapseAll, + menu: { + id: MenuId.ViewTitle, + group: 'navigation', + when: ContextKeyExpr.equals('view', SESSIONS_FILES_VIEW_ID), + }, + }); + } + + run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SESSIONS_FILES_VIEW_ID); + if (view !== null) { + (view as ExplorerView).collapseAll(); + } + } +}); diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts new file mode 100644 index 0000000000000..c212fa79443d4 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +//#region GitHub API response types + +interface IGitHubCheckRunResponse { + readonly id: number; + readonly name: string; + readonly status: string; + readonly conclusion: string | null; + readonly started_at: string | null; + readonly completed_at: string | null; + readonly details_url: string | null; +} + +interface IGitHubCheckRunsListResponse { + readonly total_count: number; + readonly check_runs: readonly IGitHubCheckRunResponse[]; +} + +interface IGitHubCheckRunAnnotationResponse { + readonly path: string; + readonly start_line: number; + readonly end_line: number; + readonly annotation_level: string; + readonly message: string; + readonly title: string | null; +} + +interface IGitHubCheckRunDetailResponse { + readonly id: number; + readonly name: string; + readonly details_url: string | null; + readonly app: { + readonly slug: string; + } | null; + readonly output: { + readonly title: string | null; + readonly summary: string | null; + readonly text: string | null; + readonly annotations_count: number; + }; +} + +//#endregion + +/** + * Stateless fetcher for GitHub CI check data (check runs, check suites). + * All methods return raw typed data with no caching or state. + */ +export class GitHubPRCIFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getCheckRuns(owner: string, repo: string, ref: string): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/commits/${e(ref)}/check-runs`, + 'githubApi.getCheckRuns' + ); + return data.check_runs.map(mapCheckRun); + } + + /** + * Get logs/output for a specific check run. + * + * Tries multiple sources in order: + * 1. The check run's own output fields (title, summary, text) — set by the + * check run creator via the Checks API. + * 2. Annotations attached to the check run. + * 3. GitHub Actions job logs (only works for GitHub Actions workflows). + */ + async getCheckRunAnnotations(owner: string, repo: string, checkRunId: number): Promise { + const sections: string[] = []; + let detail: IGitHubCheckRunDetailResponse | undefined; + + // 1. Fetch check run detail for output fields + try { + detail = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}`, + 'githubApi.getCheckRunAnnotations' + ); + const output = detail.output; + if (output.title) { + sections.push(`# ${output.title}`); + } + if (output.summary) { + sections.push(output.summary); + } + if (output.text) { + sections.push(output.text); + } + } catch { + // Ignore — output may not be available + } + + // 2. Fetch annotations + try { + const annotations = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}/annotations`, + 'githubApi.getCheckRunAnnotations.annotations' + ); + if (annotations.length > 0) { + sections.push( + annotations.map(a => + `[${a.annotation_level}] ${a.path}:${a.start_line}${a.end_line !== a.start_line ? `-${a.end_line}` : ''} ${a.title ? `(${a.title}) ` : ''}${a.message}` + ).join('\n') + ); + } + } catch { + // Ignore — annotations may not be available + } + + if (sections.length > 0) { + return sections.join('\n\n'); + } + + return 'No output available for this check run.'; + } +} + +//#region Helpers + +function e(value: string): string { + return encodeURIComponent(value); +} + +function mapCheckRun(data: IGitHubCheckRunResponse): IGitHubCICheck { + return { + id: data.id, + name: data.name, + status: mapCheckStatus(data.status), + conclusion: data.conclusion ? mapCheckConclusion(data.conclusion) : undefined, + startedAt: data.started_at ?? undefined, + completedAt: data.completed_at ?? undefined, + detailsUrl: data.details_url ?? undefined, + }; +} + +function mapCheckStatus(status: string): GitHubCheckStatus { + switch (status) { + case 'queued': return GitHubCheckStatus.Queued; + case 'in_progress': return GitHubCheckStatus.InProgress; + case 'completed': return GitHubCheckStatus.Completed; + default: return GitHubCheckStatus.Queued; + } +} + +function mapCheckConclusion(conclusion: string): GitHubCheckConclusion { + switch (conclusion) { + case 'success': return GitHubCheckConclusion.Success; + case 'failure': return GitHubCheckConclusion.Failure; + case 'neutral': return GitHubCheckConclusion.Neutral; + case 'cancelled': return GitHubCheckConclusion.Cancelled; + case 'skipped': return GitHubCheckConclusion.Skipped; + case 'timed_out': return GitHubCheckConclusion.TimedOut; + case 'action_required': return GitHubCheckConclusion.ActionRequired; + case 'stale': return GitHubCheckConclusion.Stale; + default: return GitHubCheckConclusion.Neutral; + } +} + +/** + * Compute an overall CI status from a list of check runs. + */ +export function computeOverallCIStatus(checks: readonly IGitHubCICheck[]): GitHubCIOverallStatus { + if (checks.length === 0) { + return GitHubCIOverallStatus.Neutral; + } + + let hasFailure = false; + let hasPending = false; + + for (const check of checks) { + if (check.status !== GitHubCheckStatus.Completed) { + hasPending = true; + continue; + } + if (check.conclusion === GitHubCheckConclusion.Failure || + check.conclusion === GitHubCheckConclusion.TimedOut || + check.conclusion === GitHubCheckConclusion.ActionRequired) { + hasFailure = true; + } + } + + if (hasFailure) { + return GitHubCIOverallStatus.Failure; + } + if (hasPending) { + return GitHubCIOverallStatus.Pending; + } + return GitHubCIOverallStatus.Success; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts new file mode 100644 index 0000000000000..a958e59e4c0c6 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts @@ -0,0 +1,368 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + GitHubPullRequestState, + IGitHubPRComment, + IGitHubPRReviewThread, + IGitHubPullRequest, + IGitHubPullRequestMergeability, + IGitHubUser, + IMergeBlocker, + MergeBlockerKind, +} from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +//#region GitHub API response types + +interface IGitHubPRResponse { + readonly number: number; + readonly title: string; + readonly body: string | null; + readonly state: 'open' | 'closed'; + readonly draft: boolean; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly head: { readonly ref: string; readonly sha: string }; + readonly base: { readonly ref: string }; + readonly created_at: string; + readonly updated_at: string; + readonly merged_at: string | null; + readonly mergeable: boolean | null; + readonly mergeable_state: string; + readonly merged: boolean; +} + +interface IGitHubReviewResponse { + readonly id: number; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly state: string; + readonly submitted_at: string; +} + +interface IGitHubReviewCommentResponse { + readonly id: number; + readonly body: string; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly created_at: string; + readonly updated_at: string; + readonly path: string; + readonly line: number | null; + readonly original_line: number | null; + readonly in_reply_to_id?: number; +} + +interface IGitHubIssueCommentResponse { + readonly id: number; + readonly body: string | null; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly created_at: string; + readonly updated_at: string; +} + +interface IGitHubGraphQLPullRequestReviewThreadsResponse { + readonly repository: { + readonly pullRequest: { + readonly reviewThreads: { + readonly nodes: readonly IGitHubGraphQLReviewThreadNode[]; + }; + } | null; + } | null; +} + +interface IGitHubGraphQLReviewThreadNode { + readonly id: string; + readonly isResolved: boolean; + readonly path: string; + readonly line: number | null; + readonly comments: { + readonly nodes: readonly IGitHubGraphQLReviewCommentNode[]; + }; +} + +interface IGitHubGraphQLReviewCommentNode { + readonly databaseId: number | null; + readonly body: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly path: string | null; + readonly line: number | null; + readonly originalLine: number | null; + readonly replyTo: { readonly databaseId: number | null } | null; + readonly author: { readonly login: string; readonly avatarUrl: string } | null; +} + +interface IGitHubGraphQLResolveReviewThreadResponse { + readonly resolveReviewThread: { + readonly thread: { + readonly isResolved: boolean; + } | null; + } | null; +} + +//#endregion + +const GET_REVIEW_THREADS_QUERY = [ + 'query GetReviewThreads($owner: String!, $repo: String!, $prNumber: Int!) {', + ' repository(owner: $owner, name: $repo) {', + ' pullRequest(number: $prNumber) {', + ' reviewThreads(first: 100) {', + ' nodes {', + ' id', + ' isResolved', + ' path', + ' line', + ' comments(first: 100) {', + ' nodes {', + ' databaseId', + ' body', + ' createdAt', + ' updatedAt', + ' path', + ' line', + ' originalLine', + ' replyTo {', + ' databaseId', + ' }', + ' author {', + ' login', + ' avatarUrl', + ' }', + ' }', + ' }', + ' }', + ' }', + ' }', + ' }', + '}', +].join('\n'); + +const RESOLVE_REVIEW_THREAD_MUTATION = [ + 'mutation ResolveReviewThread($threadId: ID!) {', + ' resolveReviewThread(input: { threadId: $threadId }) {', + ' thread {', + ' isResolved', + ' }', + ' }', + '}', +].join('\n'); + +/** + * Stateless fetcher for GitHub pull request data. + * Handles all PR-related REST API calls including reviews, comments, and mergeability. + */ +export class GitHubPRFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getPullRequest(owner: string, repo: string, prNumber: number): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`, + 'githubApi.getPullRequest' + ); + return mapPullRequest(data); + } + + async getMergeability(owner: string, repo: string, prNumber: number): Promise { + const [pr, reviews] = await Promise.all([ + this._apiClient.request('GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`, 'githubApi.getMergeability.pr'), + this._apiClient.request('GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/reviews`, 'githubApi.getMergeability.reviews'), + ]); + + const blockers: IMergeBlocker[] = []; + + // Draft + if (pr.draft) { + blockers.push({ kind: MergeBlockerKind.Draft, description: 'Pull request is a draft' }); + } + + // Merge conflicts + if (pr.mergeable === false) { + blockers.push({ kind: MergeBlockerKind.Conflicts, description: 'Pull request has merge conflicts' }); + } + + // Changes requested — check most recent review per reviewer + const latestReviewByUser = new Map(); + for (const review of reviews) { + if (review.state === 'APPROVED' || review.state === 'CHANGES_REQUESTED' || review.state === 'DISMISSED') { + latestReviewByUser.set(review.user.login, review.state); + } + } + const hasChangesRequested = [...latestReviewByUser.values()].some(s => s === 'CHANGES_REQUESTED'); + if (hasChangesRequested) { + blockers.push({ kind: MergeBlockerKind.ChangesRequested, description: 'Changes have been requested' }); + } + + // Approval needed — check mergeable_state + if (pr.mergeable_state === 'blocked') { + const hasApproval = [...latestReviewByUser.values()].some(s => s === 'APPROVED'); + if (!hasApproval) { + blockers.push({ kind: MergeBlockerKind.ApprovalNeeded, description: 'Approval is required' }); + } + } + + // CI failures — mergeable_state 'unstable' indicates check failures + if (pr.mergeable_state === 'unstable') { + blockers.push({ kind: MergeBlockerKind.CIFailed, description: 'CI checks have failed' }); + } + + return { + canMerge: blockers.length === 0 && pr.mergeable !== false && pr.state === 'open', + blockers, + }; + } + + async getReviewThreads(owner: string, repo: string, prNumber: number): Promise { + const data = await this._apiClient.graphql( + GET_REVIEW_THREADS_QUERY, + 'githubApi.getReviewThreads', + { owner, repo, prNumber }, + ); + + const reviewThreads = data.repository?.pullRequest?.reviewThreads.nodes; + if (!reviewThreads) { + throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`); + } + + return reviewThreads.map(mapReviewThread); + } + + async postReviewComment( + owner: string, + repo: string, + prNumber: number, + body: string, + inReplyTo: number, + ): Promise { + const data = await this._apiClient.request( + 'POST', + `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/comments`, + 'githubApi.postReviewComment', + { body, in_reply_to: inReplyTo }, + ); + return mapReviewComment(data); + } + + async postIssueComment( + owner: string, + repo: string, + prNumber: number, + body: string, + ): Promise { + const data = await this._apiClient.request( + 'POST', + `/repos/${e(owner)}/${e(repo)}/issues/${prNumber}/comments`, + 'githubApi.postIssueComment', + { body }, + ); + return { + id: data.id, + body: data.body ?? '', + author: mapUser(data.user), + createdAt: data.created_at, + updatedAt: data.updated_at, + path: undefined, + line: undefined, + threadId: String(data.id), + inReplyToId: undefined, + }; + } + + async resolveThread(_owner: string, _repo: string, threadId: string): Promise { + const data = await this._apiClient.graphql( + RESOLVE_REVIEW_THREAD_MUTATION, + 'githubApi.resolveThread', + { threadId }, + ); + + if (!data.resolveReviewThread?.thread?.isResolved) { + throw new Error(`Failed to resolve review thread ${threadId}`); + } + } +} + +//#region Helpers + +function e(value: string): string { + return encodeURIComponent(value); +} + +function mapUser(user: { readonly login: string; readonly avatar_url: string }): IGitHubUser { + return { login: user.login, avatarUrl: user.avatar_url }; +} + +function mapPullRequest(data: IGitHubPRResponse): IGitHubPullRequest { + let state: GitHubPullRequestState; + if (data.merged) { + state = GitHubPullRequestState.Merged; + } else if (data.state === 'closed') { + state = GitHubPullRequestState.Closed; + } else { + state = GitHubPullRequestState.Open; + } + + return { + number: data.number, + title: data.title, + body: data.body ?? '', + state, + author: mapUser(data.user), + headRef: data.head.ref, + headSha: data.head.sha, + baseRef: data.base.ref, + isDraft: data.draft, + createdAt: data.created_at, + updatedAt: data.updated_at, + mergedAt: data.merged_at ?? undefined, + mergeable: data.mergeable ?? undefined, + mergeableState: data.mergeable_state, + }; +} + +function mapReviewComment(data: IGitHubReviewCommentResponse): IGitHubPRComment { + return { + id: data.id, + body: data.body, + author: mapUser(data.user), + createdAt: data.created_at, + updatedAt: data.updated_at, + path: data.path, + line: data.line ?? data.original_line ?? undefined, + threadId: String(data.in_reply_to_id ?? data.id), + inReplyToId: data.in_reply_to_id, + }; +} + +function mapReviewThread(thread: IGitHubGraphQLReviewThreadNode): IGitHubPRReviewThread { + return { + id: thread.id, + isResolved: thread.isResolved, + path: thread.path, + line: thread.line ?? undefined, + comments: thread.comments.nodes.flatMap(comment => mapGraphQLReviewComment(comment, thread)), + }; +} + +function mapGraphQLReviewComment(comment: IGitHubGraphQLReviewCommentNode, thread: IGitHubGraphQLReviewThreadNode): readonly IGitHubPRComment[] { + if (comment.databaseId === null || comment.author === null) { + return []; + } + + return [{ + id: comment.databaseId, + body: comment.body, + author: { login: comment.author.login, avatarUrl: comment.author.avatarUrl }, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + path: comment.path ?? thread.path, + line: comment.line ?? comment.originalLine ?? thread.line ?? undefined, + threadId: thread.id, + inReplyToId: comment.replyTo?.databaseId ?? undefined, + }]; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts new file mode 100644 index 0000000000000..5e4a90dfa90d1 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IGitHubRepository } from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +interface IGitHubRepoResponse { + readonly name: string; + readonly full_name: string; + readonly owner: { readonly login: string }; + readonly default_branch: string; + readonly private: boolean; + readonly description: string | null; +} + +/** + * Stateless fetcher for GitHub repository data. + * All methods return raw typed data with no caching or state. + */ +export class GitHubRepositoryFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getRepository(owner: string, repo: string): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + 'githubApi.getRepository' + ); + return { + owner: data.owner.login, + name: data.name, + fullName: data.full_name, + defaultBranch: data.default_branch, + isPrivate: data.private, + description: data.description ?? '', + }; + } +} diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts new file mode 100644 index 0000000000000..1ade1c3ae83b0 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { GitHubService, IGitHubService } from './githubService.js'; + +/** + * Immediately refreshes PR data when the active session changes so that + * CI checks and PR state are up-to-date without waiting for the next + * polling cycle. + */ +class GitHubActiveSessionRefreshContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.githubActiveSessionRefresh'; + + private _lastSessionResource: URI | undefined; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IGitHubService private readonly _gitHubService: IGitHubService, + ) { + super(); + + this._register(autorun(reader => { + const session = this._sessionsManagementService.activeSession.read(reader); + if (!session) { + this._lastSessionResource = undefined; + return; + } + if (this._lastSessionResource?.toString() === session.resource.toString()) { + return; + } + this._lastSessionResource = session.resource; + const context = this._sessionsManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return; + } + const prModel = this._gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + prModel.refresh(); + })); + } +} + +registerSingleton(IGitHubService, GitHubService, InstantiationType.Delayed); +registerWorkbenchContribution2(GitHubActiveSessionRefreshContribution.ID, GitHubActiveSessionRefreshContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts new file mode 100644 index 0000000000000..ba6e1f3fc5168 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IRequestService, asJson } from '../../../../platform/request/common/request.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; + +const LOG_PREFIX = '[GitHubApiClient]'; +const GITHUB_API_BASE = 'https://api.github.com'; +const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; + +interface IGitHubGraphQLError { + readonly message: string; +} + +interface IGitHubGraphQLResponse { + readonly data?: T; + readonly errors?: readonly IGitHubGraphQLError[]; +} + +export class GitHubApiError extends Error { + constructor( + message: string, + readonly statusCode: number, + readonly rateLimitRemaining: number | undefined, + ) { + super(message); + this.name = 'GitHubApiError'; + } +} + +/** + * Low-level GitHub REST API client. Handles authentication, + * request construction, and error classification. + * + * This class is stateless with respect to domain data — it only + * manages auth tokens and raw HTTP communication. + */ +export class GitHubApiClient extends Disposable { + + constructor( + @IRequestService private readonly _requestService: IRequestService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async request(method: string, path: string, callSite: string, body?: unknown): Promise { + return this._request(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', callSite, body); + } + + async graphql(query: string, callSite: string, variables?: Record): Promise { + const response = await this._request>( + 'POST', + GITHUB_GRAPHQL_ENDPOINT, + '/graphql', + 'application/vnd.github+json', + callSite, + { query, variables }, + ); + + if (response.errors?.length) { + throw new GitHubApiError( + response.errors.map(error => error.message).join('; '), + 200, + undefined, + ); + } + + if (!response.data) { + throw new GitHubApiError('GitHub GraphQL response did not include data', 200, undefined); + } + + return response.data; + } + + private async _request(method: string, url: string, pathForLogging: string, accept: string, callSite: string, body?: unknown): Promise { + const token = await this._getAuthToken(); + + this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + + const response = await this._requestService.request({ + type: method, + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': accept, + 'User-Agent': 'VSCode-Sessions-GitHub', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + data: body !== undefined ? JSON.stringify(body) : undefined, + callSite + }, CancellationToken.None); + + const rateLimitRemaining = parseRateLimitHeader(response.res.headers?.['x-ratelimit-remaining']); + if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { + this._logService.warn(`${LOG_PREFIX} GitHub API rate limit low: ${rateLimitRemaining} remaining`); + } + + const statusCode = response.res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + const errorBody = await asJson<{ message?: string }>(response).catch(() => undefined); + throw new GitHubApiError( + errorBody?.message ?? `GitHub API request failed: ${method} ${pathForLogging} (${statusCode})`, + statusCode, + rateLimitRemaining, + ); + } + + if (statusCode === 204) { + return undefined as unknown as T; + } + + const data = await asJson(response); + if (!data) { + throw new GitHubApiError( + `Failed to parse response for ${method} ${pathForLogging}`, + statusCode, + rateLimitRemaining, + ); + } + + return data; + } + + private async _getAuthToken(): Promise { + let sessions = await this._authenticationService.getSessions('github', [], { silent: true }); + if (!sessions || sessions.length === 0) { + sessions = await this._authenticationService.getSessions('github', [], { createIfNone: true }); + } + if (!sessions || sessions.length === 0) { + throw new Error('No GitHub authentication sessions available'); + } + return sessions[0].accessToken ?? ''; + } +} + +function parseRateLimitHeader(value: string | string[] | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const str = Array.isArray(value) ? value[0] : value; + const parsed = parseInt(str, 10); + return isNaN(parsed) ? undefined : parsed; +} diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts new file mode 100644 index 0000000000000..ac6a5ab7de836 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { GitHubApiClient } from './githubApiClient.js'; +import { GitHubRepositoryFetcher } from './fetchers/githubRepositoryFetcher.js'; +import { GitHubPRFetcher } from './fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher } from './fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryModel } from './models/githubRepositoryModel.js'; +import { GitHubPullRequestModel } from './models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from './models/githubPullRequestCIModel.js'; + +export interface IGitHubService { + readonly _serviceBrand: undefined; + + /** + * Get or create a reactive model for a GitHub repository. + * The model is cached by owner/repo key and disposed when the service is disposed. + */ + getRepository(owner: string, repo: string): GitHubRepositoryModel; + + /** + * Get or create a reactive model for a GitHub pull request. + * The model is cached by owner/repo/prNumber key and disposed when the service is disposed. + */ + getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel; + + /** + * Get or create a reactive model for CI checks on a pull request head ref. + * The model is cached by owner/repo/headRef key and disposed when the service is disposed. + */ + getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel; +} + +export const IGitHubService = createDecorator('sessionsGitHubService'); + +const LOG_PREFIX = '[GitHubService]'; + +export class GitHubService extends Disposable implements IGitHubService { + + declare readonly _serviceBrand: undefined; + + private readonly _apiClient: GitHubApiClient; + private readonly _repoFetcher: GitHubRepositoryFetcher; + private readonly _prFetcher: GitHubPRFetcher; + private readonly _ciFetcher: GitHubPRCIFetcher; + + private readonly _repositories = this._register(new DisposableMap()); + private readonly _pullRequests = this._register(new DisposableMap()); + private readonly _ciModels = this._register(new DisposableMap()); + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._apiClient = this._register(instantiationService.createInstance(GitHubApiClient)); + this._repoFetcher = new GitHubRepositoryFetcher(this._apiClient); + this._prFetcher = new GitHubPRFetcher(this._apiClient); + this._ciFetcher = new GitHubPRCIFetcher(this._apiClient); + } + + getRepository(owner: string, repo: string): GitHubRepositoryModel { + const key = `${owner}/${repo}`; + let model = this._repositories.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating repository model for ${key}`); + model = new GitHubRepositoryModel(owner, repo, this._repoFetcher, this._logService); + this._repositories.set(key, model); + } + return model; + } + + getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._pullRequests.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating PR model for ${key}`); + model = new GitHubPullRequestModel(owner, repo, prNumber, this._prFetcher, this._logService); + this._pullRequests.set(key, model); + } + return model; + } + + getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel { + const key = `${owner}/${repo}/${headRef}`; + let model = this._ciModels.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating CI model for ${key}`); + model = new GitHubPullRequestCIModel(owner, repo, headRef, this._ciFetcher, this._logService); + this._ciModels.set(key, model); + } + return model; + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts new file mode 100644 index 0000000000000..6a1dd490aaf88 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; +import { computeOverallCIStatus, GitHubPRCIFetcher } from '../fetchers/githubPRCIFetcher.js'; + +const LOG_PREFIX = '[GitHubPullRequestCIModel]'; +const DEFAULT_POLL_INTERVAL_MS = 60_000; + +/** + * Reactive model for CI check status on a pull request head ref. + * Wraps fetcher data in observables and supports periodic polling. + */ +export class GitHubPullRequestCIModel extends Disposable { + + private readonly _checks = observableValue(this, []); + readonly checks: IObservable = this._checks; + + private readonly _overallStatus = observableValue(this, GitHubCIOverallStatus.Neutral); + readonly overallStatus: IObservable = this._overallStatus; + + private readonly _pollScheduler: RunOnceScheduler; + private _disposed = false; + + constructor( + readonly owner: string, + readonly repo: string, + readonly headRef: string, + private readonly _fetcher: GitHubPRCIFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** + * Refresh all CI check data. + */ + async refresh(): Promise { + try { + const checks = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef); + this._checks.set(checks, undefined); + this._overallStatus.set(computeOverallCIStatus(checks), undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headRef}:`, err); + } + } + + /** + * Get annotations (structured logs) for a specific check run. + */ + async getCheckRunAnnotations(checkRunId: number): Promise { + return this._fetcher.getCheckRunAnnotations(this.owner, this.repo, checkRunId); + } + + /** + * Start periodic polling. Each cycle refreshes CI check data. + */ + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + this._pollScheduler.cancel(); + this._pollScheduler.schedule(intervalMs); + } + + /** + * Stop periodic polling. + */ + stopPolling(): void { + this._pollScheduler.cancel(); + } + + private async _poll(): Promise { + await this.refresh(); + // Re-schedule if not disposed (RunOnceScheduler is one-shot) + if (!this._disposed) { + this._pollScheduler.schedule(); + } + } + + override dispose(): void { + this._disposed = true; + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts new file mode 100644 index 0000000000000..8c5a667460c71 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubPRComment, IGitHubPRReviewThread, IGitHubPullRequest, IGitHubPullRequestMergeability } from '../../common/types.js'; +import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; + +const LOG_PREFIX = '[GitHubPullRequestModel]'; +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +/** + * Reactive model for a GitHub pull request. Wraps fetcher data in + * observables, supports on-demand refresh, and can poll periodically. + */ +export class GitHubPullRequestModel extends Disposable { + + private readonly _pullRequest = observableValue(this, undefined); + readonly pullRequest: IObservable = this._pullRequest; + + private readonly _mergeability = observableValue(this, undefined); + readonly mergeability: IObservable = this._mergeability; + + private readonly _reviewThreads = observableValue(this, []); + readonly reviewThreads: IObservable = this._reviewThreads; + + private readonly _pollScheduler: RunOnceScheduler; + private _disposed = false; + + constructor( + readonly owner: string, + readonly repo: string, + readonly prNumber: number, + private readonly _fetcher: GitHubPRFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** + * Refresh all PR data: pull request info, mergeability, and review threads. + */ + async refresh(): Promise { + await Promise.all([ + this._refreshPullRequest(), + this._refreshMergeability(), + this._refreshThreads(), + ]); + } + + /** + * Refresh only the review threads. + */ + async refreshThreads(): Promise { + await this._refreshThreads(); + } + + /** + * Post a reply to an existing review thread and refresh threads. + */ + async postReviewComment(body: string, inReplyTo: number): Promise { + const comment = await this._fetcher.postReviewComment(this.owner, this.repo, this.prNumber, body, inReplyTo); + await this._refreshThreads(); + return comment; + } + + /** + * Post a top-level issue comment on the PR. + */ + async postIssueComment(body: string): Promise { + return this._fetcher.postIssueComment(this.owner, this.repo, this.prNumber, body); + } + + /** + * Resolve a review thread and refresh the thread list. + */ + async resolveThread(threadId: string): Promise { + await this._fetcher.resolveThread(this.owner, this.repo, threadId); + await this._refreshThreads(); + } + + /** + * Start periodic polling. Each cycle refreshes all PR data. + */ + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + this._pollScheduler.cancel(); + this._pollScheduler.schedule(intervalMs); + } + + /** + * Stop periodic polling. + */ + stopPolling(): void { + this._pollScheduler.cancel(); + } + + private async _poll(): Promise { + await this.refresh(); + // Re-schedule for next poll cycle (RunOnceScheduler is one-shot) + if (!this._disposed) { + this._pollScheduler.schedule(); + } + } + + override dispose(): void { + this._disposed = true; + super.dispose(); + } + + private async _refreshPullRequest(): Promise { + try { + const data = await this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber); + this._pullRequest.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); + } + } + + private async _refreshMergeability(): Promise { + try { + const data = await this._fetcher.getMergeability(this.owner, this.repo, this.prNumber); + this._mergeability.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh mergeability for PR #${this.prNumber}:`, err); + } + } + + private async _refreshThreads(): Promise { + try { + const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); + this._reviewThreads.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); + } + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts new file mode 100644 index 0000000000000..9e2c368a329ab --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubRepository } from '../../common/types.js'; +import { GitHubRepositoryFetcher } from '../fetchers/githubRepositoryFetcher.js'; + +const LOG_PREFIX = '[GitHubRepositoryModel]'; + +/** + * Reactive model for a GitHub repository. Wraps fetcher data + * in observables and supports on-demand refresh. + */ +export class GitHubRepositoryModel extends Disposable { + + private readonly _repository = observableValue(this, undefined); + readonly repository: IObservable = this._repository; + + constructor( + readonly owner: string, + readonly repo: string, + private readonly _fetcher: GitHubRepositoryFetcher, + private readonly _logService: ILogService, + ) { + super(); + } + + async refresh(): Promise { + try { + const data = await this._fetcher.getRepository(this.owner, this.repo); + this._repository.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh repository ${this.owner}/${this.repo}:`, err); + } + } +} diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts new file mode 100644 index 0000000000000..88044e60c3393 --- /dev/null +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//#region Session Context + +/** + * GitHub context derived from an active session, providing + * the owner/repo and optionally the PR number. + */ +export interface IGitHubSessionContext { + readonly owner: string; + readonly repo: string; + readonly prNumber: number | undefined; +} + +//#endregion + +//#region Repository + +export interface IGitHubRepository { + readonly owner: string; + readonly name: string; + readonly fullName: string; + readonly defaultBranch: string; + readonly isPrivate: boolean; + readonly description: string; +} + +//#endregion + +//#region Pull Request + +export const enum GitHubPullRequestState { + Open = 'open', + Closed = 'closed', + Merged = 'merged', +} + +export interface IGitHubUser { + readonly login: string; + readonly avatarUrl: string; +} + +export interface IGitHubPullRequest { + readonly number: number; + readonly title: string; + readonly body: string; + readonly state: GitHubPullRequestState; + readonly author: IGitHubUser; + readonly headRef: string; + readonly headSha: string; + readonly baseRef: string; + readonly isDraft: boolean; + readonly createdAt: string; + readonly updatedAt: string; + readonly mergedAt: string | undefined; + readonly mergeable: boolean | undefined; + readonly mergeableState: string; +} + +export const enum MergeBlockerKind { + ChangesRequested = 'changesRequested', + CIFailed = 'ciFailed', + ApprovalNeeded = 'approvalNeeded', + Conflicts = 'conflicts', + Draft = 'draft', + Unknown = 'unknown', +} + +export interface IMergeBlocker { + readonly kind: MergeBlockerKind; + readonly description: string; +} + +export interface IGitHubPullRequestMergeability { + readonly canMerge: boolean; + readonly blockers: readonly IMergeBlocker[]; +} + +//#endregion + +//#region Review Comments & Threads + +export interface IGitHubPRComment { + readonly id: number; + readonly body: string; + readonly author: IGitHubUser; + readonly createdAt: string; + readonly updatedAt: string; + /** File path the comment is attached to (undefined for issue-level comments). */ + readonly path: string | undefined; + /** Line number in the diff the comment is attached to. */ + readonly line: number | undefined; + /** The id of the thread this comment belongs to. */ + readonly threadId: string; + /** Whether this is a reply to another comment in the thread. */ + readonly inReplyToId: number | undefined; +} + +export interface IGitHubPRReviewThread { + readonly id: string; + readonly isResolved: boolean; + readonly path: string; + readonly line: number | undefined; + readonly comments: readonly IGitHubPRComment[]; +} + +//#endregion + +//#region CI Checks + +export const enum GitHubCheckStatus { + Queued = 'queued', + InProgress = 'in_progress', + Completed = 'completed', +} + +export const enum GitHubCheckConclusion { + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', + Cancelled = 'cancelled', + Skipped = 'skipped', + TimedOut = 'timed_out', + ActionRequired = 'action_required', + Stale = 'stale', +} + +export interface IGitHubCICheck { + readonly id: number; + readonly name: string; + readonly status: GitHubCheckStatus; + readonly conclusion: GitHubCheckConclusion | undefined; + readonly startedAt: string | undefined; + readonly completedAt: string | undefined; + readonly detailsUrl: string | undefined; +} + +export const enum GitHubCIOverallStatus { + Pending = 'pending', + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts new file mode 100644 index 0000000000000..db4a8b5d88323 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -0,0 +1,446 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher, computeOverallCIStatus } from '../../browser/fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; +import { GitHubApiClient, GitHubApiError } from '../../browser/githubApiClient.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, GitHubPullRequestState, MergeBlockerKind } from '../../common/types.js'; + +class MockApiClient { + + private _nextResponse: unknown; + private _nextError: Error | undefined; + readonly requestCalls: { method: string; path: string; body?: unknown }[] = []; + readonly graphqlCalls: { query: string; variables?: Record }[] = []; + + setNextResponse(data: unknown): void { + this._nextResponse = data; + this._nextError = undefined; + } + + setNextError(error: Error): void { + this._nextError = error; + this._nextResponse = undefined; + } + + async request(_method: string, _path: string, _callSite: string, _body?: unknown): Promise { + this.requestCalls.push({ method: _method, path: _path, body: _body }); + if (this._nextError) { + throw this._nextError; + } + return this._nextResponse as T; + } + + async graphql(query: string, _callSite: string, variables?: Record): Promise { + this.graphqlCalls.push({ query, variables }); + if (this._nextError) { + throw this._nextError; + } + return this._nextResponse as T; + } +} + +suite('GitHubRepositoryFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubRepositoryFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubRepositoryFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getRepository returns mapped data', async () => { + mockApi.setNextResponse({ + name: 'vscode', + full_name: 'microsoft/vscode', + owner: { login: 'microsoft' }, + default_branch: 'main', + private: false, + description: 'Visual Studio Code', + }); + + const repo = await fetcher.getRepository('microsoft', 'vscode'); + assert.deepStrictEqual(repo, { + owner: 'microsoft', + name: 'vscode', + fullName: 'microsoft/vscode', + defaultBranch: 'main', + isPrivate: false, + description: 'Visual Studio Code', + }); + assert.strictEqual(mockApi.requestCalls[0].path, '/repos/microsoft/vscode'); + }); + + test('getRepository handles null description', async () => { + mockApi.setNextResponse({ + name: 'test', + full_name: 'owner/test', + owner: { login: 'owner' }, + default_branch: 'main', + private: true, + description: null, + }); + + const repo = await fetcher.getRepository('owner', 'test'); + assert.strictEqual(repo.description, ''); + }); + + test('getRepository propagates API errors', async () => { + mockApi.setNextError(new GitHubApiError('Not found', 404, undefined)); + await assert.rejects( + () => fetcher.getRepository('owner', 'nonexistent'), + (err: Error) => err instanceof GitHubApiError && (err as GitHubApiError).statusCode === 404, + ); + }); +}); + +suite('GitHubPRFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubPRFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubPRFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getPullRequest maps open PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'open', merged: false, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Open); + assert.strictEqual(pr.isDraft, false); + assert.strictEqual(pr.number, 1); + assert.strictEqual(pr.title, 'Test PR'); + }); + + test('getPullRequest maps merged PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: true, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Merged); + assert.ok(pr.mergedAt); + }); + + test('getPullRequest maps closed PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: false, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Closed); + }); + + test('getReviewThreads returns GraphQL thread metadata', async () => { + mockApi.setNextResponse(makeGraphQLReviewThreadsResponse([ + makeGraphQLReviewThread({ + id: 'thread-a', + path: 'src/a.ts', + line: 10, + isResolved: false, + comments: [ + makeGraphQLReviewComment({ databaseId: 100, path: 'src/a.ts', line: 10 }), + makeGraphQLReviewComment({ databaseId: 101, path: 'src/a.ts', line: 10, replyToDatabaseId: 100 }), + ], + }), + makeGraphQLReviewThread({ + id: 'thread-b', + path: 'src/b.ts', + line: 20, + isResolved: true, + comments: [makeGraphQLReviewComment({ databaseId: 200, path: 'src/b.ts', line: 20 })], + }), + ])); + + const threads = await fetcher.getReviewThreads('owner', 'repo', 1); + assert.strictEqual(threads.length, 2); + + const thread1 = threads.find(t => t.id === 'thread-a')!; + assert.ok(thread1); + assert.strictEqual(thread1.comments.length, 2); + assert.strictEqual(thread1.path, 'src/a.ts'); + assert.strictEqual(thread1.line, 10); + assert.strictEqual(thread1.comments[0].threadId, 'thread-a'); + + const thread2 = threads.find(t => t.id === 'thread-b')!; + assert.ok(thread2); + assert.strictEqual(thread2.comments.length, 1); + assert.strictEqual(thread2.path, 'src/b.ts'); + assert.strictEqual(thread2.isResolved, true); + }); + + test('resolveThread uses GraphQL mutation', async () => { + mockApi.setNextResponse({ + resolveReviewThread: { + thread: { + isResolved: true, + }, + }, + }); + + await fetcher.resolveThread('owner', 'repo', 'thread-a'); + assert.strictEqual(mockApi.graphqlCalls.length, 1); + assert.deepStrictEqual(mockApi.graphqlCalls[0].variables, { threadId: 'thread-a' }); + }); + + test('getMergeability detects draft blocker', async () => { + // getMergeability makes two requests (PR then reviews) + // Use a counter to return different responses + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (_method: string, _path: string, _body?: unknown): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: true, mergeable: true, mergeable_state: 'clean' }) as T; + } + return [] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.Draft)); + + // Restore + mockApi.request = originalRequest; + }); + + test('getMergeability detects conflicts blocker', async () => { + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: false, mergeable: false, mergeable_state: 'dirty' }) as T; + } + return [] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.Conflicts)); + + mockApi.request = originalRequest; + }); + + test('getMergeability detects changes requested blocker', async () => { + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: false, mergeable: true, mergeable_state: 'clean' }) as T; + } + return [ + { id: 1, user: { login: 'reviewer', avatar_url: '' }, state: 'CHANGES_REQUESTED', submitted_at: '2024-01-01T00:00:00Z' }, + ] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.ChangesRequested)); + + mockApi.request = originalRequest; + }); +}); + +suite('GitHubPRCIFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubPRCIFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubPRCIFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getCheckRuns maps check runs', async () => { + mockApi.setNextResponse({ + total_count: 2, + check_runs: [ + { id: 1, name: 'build', status: 'completed', conclusion: 'success', started_at: '2024-01-01T00:00:00Z', completed_at: '2024-01-01T00:10:00Z', details_url: 'https://example.com/1' }, + { id: 2, name: 'test', status: 'in_progress', conclusion: null, started_at: '2024-01-01T00:00:00Z', completed_at: null, details_url: null }, + ], + }); + + const checks = await fetcher.getCheckRuns('owner', 'repo', 'abc123'); + assert.strictEqual(checks.length, 2); + assert.deepStrictEqual(checks[0], { + id: 1, + name: 'build', + status: GitHubCheckStatus.Completed, + conclusion: GitHubCheckConclusion.Success, + startedAt: '2024-01-01T00:00:00Z', + completedAt: '2024-01-01T00:10:00Z', + detailsUrl: 'https://example.com/1', + }); + assert.strictEqual(checks[1].conclusion, undefined); + }); + + test('getCheckRunAnnotations returns formatted annotations', async () => { + mockApi.setNextResponse([ + { path: 'src/a.ts', start_line: 10, end_line: 10, annotation_level: 'failure', message: 'type error', title: 'TS2345' }, + { path: 'src/b.ts', start_line: 5, end_line: 8, annotation_level: 'warning', message: 'unused var', title: null }, + ]); + + const result = await fetcher.getCheckRunAnnotations('owner', 'repo', 1); + assert.ok(result.includes('[failure] src/a.ts:10')); + assert.ok(result.includes('(TS2345)')); + assert.ok(result.includes('[warning] src/b.ts:5-8')); + }); +}); + +suite('computeOverallCIStatus', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns neutral for empty checks', () => { + assert.strictEqual(computeOverallCIStatus([]), GitHubCIOverallStatus.Neutral); + }); + + test('returns success when all completed successfully', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Neutral }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Success); + }); + + test('returns failure when any check failed', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Failure); + }); + + test('returns pending when any check is in progress', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.InProgress, conclusion: undefined }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Pending); + }); + + test('failure takes precedence over pending', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure }), + makeCheck({ status: GitHubCheckStatus.InProgress, conclusion: undefined }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Failure); + }); +}); + + +//#region Test Helpers + +function makePRResponse(overrides: { + state: 'open' | 'closed'; + merged: boolean; + draft: boolean; + mergeable?: boolean | null; + mergeable_state?: string; +}): unknown { + return { + number: 1, + title: 'Test PR', + body: 'Test body', + state: overrides.state, + draft: overrides.draft, + user: { login: 'author', avatar_url: 'https://example.com/avatar' }, + head: { ref: 'feature-branch' }, + base: { ref: 'main' }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + merged_at: overrides.merged ? '2024-01-02T00:00:00Z' : null, + mergeable: overrides.mergeable ?? true, + mergeable_state: overrides.mergeable_state ?? 'clean', + merged: overrides.merged, + }; +} + +function makeGraphQLReviewThreadsResponse(threads: readonly ReturnType[]): unknown { + return { + repository: { + pullRequest: { + reviewThreads: { + nodes: threads, + }, + }, + }, + }; +} + +function makeGraphQLReviewThread(overrides: Partial<{ + id: string; + isResolved: boolean; + path: string; + line: number; + comments: readonly ReturnType[]; +}> = {}): unknown { + return { + id: overrides.id ?? 'thread-1', + isResolved: overrides.isResolved ?? false, + path: overrides.path ?? 'src/a.ts', + line: overrides.line ?? 10, + comments: { + nodes: overrides.comments ?? [makeGraphQLReviewComment()], + }, + }; +} + +function makeGraphQLReviewComment(overrides: Partial<{ + databaseId: number; + body: string; + path: string; + line: number; + replyToDatabaseId: number; +}> = {}): unknown { + return { + databaseId: overrides.databaseId ?? 100, + body: overrides.body ?? 'Test comment', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + path: overrides.path ?? 'src/a.ts', + line: overrides.line ?? 10, + originalLine: overrides.line ?? 10, + replyTo: overrides.replyToDatabaseId !== undefined ? { databaseId: overrides.replyToDatabaseId } : null, + author: { + login: 'reviewer', + avatarUrl: 'https://example.com/avatar', + }, + }; +} + +function makeCheck(overrides: { + status: GitHubCheckStatus; + conclusion: GitHubCheckConclusion | undefined; +}): { id: number; name: string; status: GitHubCheckStatus; conclusion: GitHubCheckConclusion | undefined; startedAt: string | undefined; completedAt: string | undefined; detailsUrl: string | undefined } { + return { + id: 1, + name: 'test-check', + status: overrides.status, + conclusion: overrides.conclusion, + startedAt: undefined, + completedAt: undefined, + detailsUrl: undefined, + }; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts new file mode 100644 index 0000000000000..8f45b6b5ad165 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; +import { GitHubRepositoryModel } from '../../browser/models/githubRepositoryModel.js'; +import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher } from '../../browser/fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; +import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPRReviewThread, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubRepository } from '../../common/types.js'; + +//#region Mock Fetchers + +class MockRepositoryFetcher { + nextResult: IGitHubRepository | undefined; + + async getRepository(_owner: string, _repo: string): Promise { + if (!this.nextResult) { + throw new Error('No mock result'); + } + return this.nextResult; + } +} + +class MockPRFetcher { + nextPR: IGitHubPullRequest | undefined; + nextMergeability: IGitHubPullRequestMergeability | undefined; + nextThreads: IGitHubPRReviewThread[] = []; + postReviewCommentCalls: { body: string; inReplyTo: number }[] = []; + postIssueCommentCalls: { body: string }[] = []; + + async getPullRequest(_owner: string, _repo: string, _prNumber: number): Promise { + if (!this.nextPR) { + throw new Error('No mock PR'); + } + return this.nextPR; + } + + async getMergeability(_owner: string, _repo: string, _prNumber: number): Promise { + if (!this.nextMergeability) { + throw new Error('No mock mergeability'); + } + return this.nextMergeability; + } + + async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { + return this.nextThreads; + } + + async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise { + this.postReviewCommentCalls.push({ body, inReplyTo }); + return makeComment(999, body); + } + + async postIssueComment(_owner: string, _repo: string, _prNumber: number, body: string): Promise { + this.postIssueCommentCalls.push({ body }); + return makeComment(998, body); + } + + async resolveThread(): Promise { + throw new Error('Not implemented'); + } +} + +class MockCIFetcher { + nextChecks: IGitHubCICheck[] = []; + + async getCheckRuns(_owner: string, _repo: string, _ref: string): Promise { + return this.nextChecks; + } + + async getCheckRunAnnotations(_owner: string, _repo: string, _checkRunId: number): Promise { + return 'mock annotations'; + } +} + +//#endregion + +suite('GitHubRepositoryModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockRepositoryFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockRepositoryFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state is undefined', () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + assert.strictEqual(model.repository.get(), undefined); + }); + + test('refresh populates repository observable', async () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + mockFetcher.nextResult = { + owner: 'owner', + name: 'repo', + fullName: 'owner/repo', + defaultBranch: 'main', + isPrivate: false, + description: 'test', + }; + + await model.refresh(); + assert.deepStrictEqual(model.repository.get(), mockFetcher.nextResult); + }); + + test('refresh handles errors gracefully', async () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + // No nextResult set, will throw + await model.refresh(); + assert.strictEqual(model.repository.get(), undefined); + }); +}); + +suite('GitHubPullRequestModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockPRFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockPRFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state has empty observables', () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + assert.strictEqual(model.pullRequest.get(), undefined); + assert.strictEqual(model.mergeability.get(), undefined); + assert.deepStrictEqual(model.reviewThreads.get(), []); + }); + + test('refresh populates all observables', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextPR = makePR(); + mockFetcher.nextMergeability = { canMerge: true, blockers: [] }; + mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts')]; + + await model.refresh(); + assert.strictEqual(model.pullRequest.get()?.number, 1); + assert.strictEqual(model.mergeability.get()?.canMerge, true); + assert.strictEqual(model.reviewThreads.get().length, 1); + }); + + test('refreshThreads only updates threads', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts'), makeThread('thread-200', 'src/b.ts')]; + + await model.refreshThreads(); + assert.strictEqual(model.pullRequest.get(), undefined); // not refreshed + assert.strictEqual(model.reviewThreads.get().length, 2); + }); + + test('postReviewComment calls fetcher and refreshes threads', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = []; + + const comment = await model.postReviewComment('LGTM', 100); + assert.strictEqual(comment.body, 'LGTM'); + assert.strictEqual(mockFetcher.postReviewCommentCalls.length, 1); + assert.strictEqual(mockFetcher.postReviewCommentCalls[0].body, 'LGTM'); + }); + + test('postIssueComment calls fetcher', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + + const comment = await model.postIssueComment('Great work!'); + assert.strictEqual(comment.body, 'Great work!'); + assert.strictEqual(mockFetcher.postIssueCommentCalls.length, 1); + }); + + test('polling can be started and stopped', () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + // Just ensure no errors; actual polling behavior is timer-based + model.startPolling(60_000); + model.stopPolling(); + }); +}); + +suite('GitHubPullRequestCIModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockCIFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockCIFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state is empty', () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + assert.deepStrictEqual(model.checks.get(), []); + assert.strictEqual(model.overallStatus.get(), GitHubCIOverallStatus.Neutral); + }); + + test('refresh populates checks and computes overall status', async () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + mockFetcher.nextChecks = [ + { id: 1, name: 'build', status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success, startedAt: undefined, completedAt: undefined, detailsUrl: undefined }, + { id: 2, name: 'test', status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure, startedAt: undefined, completedAt: undefined, detailsUrl: undefined }, + ]; + + await model.refresh(); + assert.strictEqual(model.checks.get().length, 2); + assert.strictEqual(model.overallStatus.get(), GitHubCIOverallStatus.Failure); + }); + + test('getCheckRunAnnotations delegates to fetcher', async () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + const result = await model.getCheckRunAnnotations(1); + assert.strictEqual(result, 'mock annotations'); + }); +}); + + +//#region Test Helpers + +function makePR(): IGitHubPullRequest { + return { + number: 1, + title: 'Test PR', + body: 'Test body', + state: GitHubPullRequestState.Open, + author: { login: 'author', avatarUrl: '' }, + headRef: 'feature', + headSha: 'abc123', + baseRef: 'main', + isDraft: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', + }; +} + +function makeThread(id: string, path: string): IGitHubPRReviewThread { + return { + id, + isResolved: false, + path, + line: 10, + comments: [makeComment(100, `Comment on ${path}`, id)], + }; +} + +function makeComment(id: number, body: string, threadId: string = String(id)): IGitHubPRComment { + return { + id, + body, + author: { login: 'reviewer', avatarUrl: '' }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + path: undefined, + line: undefined, + threadId, + inReplyToId: undefined, + }; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts new file mode 100644 index 0000000000000..d743eefdaddd2 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { GitHubService } from '../../browser/githubService.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionData.js'; + +suite('GitHubService', () => { + + const store = new DisposableStore(); + let service: GitHubService; + + setup(() => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + + service = store.add(instantiationService.createInstance(GitHubService)); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getRepository returns cached model for same key', () => { + const model1 = service.getRepository('owner', 'repo'); + const model2 = service.getRepository('owner', 'repo'); + assert.strictEqual(model1, model2); + }); + + test('getRepository returns different models for different repos', () => { + const model1 = service.getRepository('owner', 'repo1'); + const model2 = service.getRepository('owner', 'repo2'); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequest returns cached model for same key', () => { + const model1 = service.getPullRequest('owner', 'repo', 1); + const model2 = service.getPullRequest('owner', 'repo', 1); + assert.strictEqual(model1, model2); + }); + + test('getPullRequest returns different models for different PRs', () => { + const model1 = service.getPullRequest('owner', 'repo', 1); + const model2 = service.getPullRequest('owner', 'repo', 2); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequestCI returns cached model for same key', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 'abc123'); + const model2 = service.getPullRequestCI('owner', 'repo', 'abc123'); + assert.strictEqual(model1, model2); + }); + + test('getPullRequestCI returns different models for different refs', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 'abc'); + const model2 = service.getPullRequestCI('owner', 'repo', 'def'); + assert.notStrictEqual(model1, model2); + }); + + test('disposing service does not throw', () => { + service.getRepository('owner', 'repo'); + service.getPullRequest('owner', 'repo', 1); + service.getPullRequestCI('owner', 'repo', 'abc'); + + // Disposing the service should not throw and should clean up models + assert.doesNotThrow(() => service.dispose()); + }); +}); + +suite('getGitHubContext', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function makeSession(overrides: { repository?: URI }): { repository: URI | undefined } { + return { + repository: undefined, + ...overrides, + }; + } + + test('parses owner/repo from github-remote-file URI', () => { + const session = makeSession({ + repository: URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: '/microsoft/vscode/main' + }), + }); + + const parts = session.repository!.path.split('/').filter(Boolean); + assert.strictEqual(parts.length >= 2, true); + assert.strictEqual(decodeURIComponent(parts[0]), 'microsoft'); + assert.strictEqual(decodeURIComponent(parts[1]), 'vscode'); + }); + + test('parses PR number from pullRequestUrl', () => { + const url = 'https://github.com/microsoft/vscode/pull/12345'; + const match = /\/pull\/(\d+)/.exec(url); + assert.ok(match); + assert.strictEqual(parseInt(match![1], 10), 12345); + }); + + test('parses owner/repo from repositoryNwo', () => { + const nwo = 'microsoft/vscode'; + const parts = nwo.split('/'); + assert.strictEqual(parts.length, 2); + assert.strictEqual(parts[0], 'microsoft'); + assert.strictEqual(parts[1], 'vscode'); + }); + + test('returns undefined for non-GitHub file URI', () => { + const session = makeSession({ + repository: URI.file('/local/path/to/repo'), + }); + + // file:// scheme is not github-remote-file + assert.notStrictEqual(session.repository!.scheme, GITHUB_REMOTE_FILE_SCHEME); + }); +}); diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts index e6de07b259404..8a562ce4977c8 100644 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts @@ -5,11 +5,8 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { SessionsCategories } from '../../../common/categories.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; @@ -17,19 +14,18 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; -const CONTEXT_SESSIONS_SHOW_LOGS = new RawContextKey('sessionsShowLogs', false); - const logsViewIcon = registerIcon('sessions-logs-view-icon', Codicon.output, localize('sessionsLogsViewIcon', 'View icon of the logs view in the sessions window.')); class RegisterLogsViewContainerContribution implements IWorkbenchContribution { static readonly ID = 'sessions.registerLogsViewContainer'; - constructor() { + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + ) { const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); @@ -63,28 +59,9 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(OutputViewPane), canToggleVisibility: true, canMoveView: false, - when: CONTEXT_SESSIONS_SHOW_LOGS, windowVisibility: WindowVisibility.Sessions, }], logsViewContainer); } } registerWorkbenchContribution2(RegisterLogsViewContainerContribution.ID, RegisterLogsViewContainerContribution, WorkbenchPhase.BlockStartup); - -// Command: Sessions: Show Logs -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'workbench.sessions.action.showLogs', - title: localize2('sessionsShowLogs', "Show Logs"), - category: SessionsCategories.Sessions, - f1: true, - }); - } - async run(accessor: ServicesAccessor): Promise { - const contextKeyService = accessor.get(IContextKeyService); - const viewsService = accessor.get(IViewsService); - CONTEXT_SESSIONS_SHOW_LOGS.bindTo(contextKeyService).set(true); - await viewsService.openView(OUTPUT_VIEW_ID, true); - } -}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts new file mode 100644 index 0000000000000..2c14cad621a2c --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -0,0 +1,434 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import * as nls from '../../../../nls.js'; +import { AgentHostFileSystemProvider } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; +import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { resolveTokenForResource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; +import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; +import { AgentHostSessionListController } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.js'; +import { LoggingAgentConnection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; +import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { ISessionsManagementService } from '../../../contrib/sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; + +/** + * Given a sanitized URI authority, resolves the corresponding agent host + * session target string by looking up the matching connection. + * + * Returns `undefined` if no connection matches the authority. + */ +export function getRemoteAgentHostSessionTarget( + connections: readonly IRemoteAgentHostConnectionInfo[], + authority: string, +): AgentSessionTarget | undefined { + for (const conn of connections) { + if (agentHostAuthority(conn.address) === authority) { + return `remote-${agentHostAuthority(conn.address)}-copilot`; + } + } + return undefined; +} + +/** Per-connection state bundle, disposed when a connection is removed. */ +class ConnectionState extends Disposable { + readonly store = this._register(new DisposableStore()); + readonly clientState: SessionClientState; + readonly agents = this._register(new DisposableMap()); + readonly modelProviders = new Map(); + readonly loggedConnection: LoggingAgentConnection; + + constructor( + clientId: string, + readonly name: string | undefined, + logService: ILogService, + loggedConnection: LoggingAgentConnection, + ) { + super(); + this.clientState = this.store.add(new SessionClientState(clientId, logService)); + this.loggedConnection = this.store.add(loggedConnection); + } +} + +/** + * Discovers available agents from each connected remote agent host and + * dynamically registers each one as a chat session type with its own + * session handler, list controller, and language model provider. + * + * Uses the same unified {@link AgentHostSessionHandler} and + * {@link AgentHostSessionListController} as the local agent host, + * obtaining per-connection {@link IAgentConnection} instances from + * {@link IRemoteAgentHostService.getConnection}. + */ +export class RemoteAgentHostContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.remoteAgentHostContribution'; + + /** Per-connection state: client state + per-agent registrations. */ + private readonly _connections = this._register(new DisposableMap()); + + /** Maps sanitized authority strings back to original addresses. */ + private readonly _fsProvider: AgentHostFileSystemProvider; + + constructor( + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IFileService private readonly _fileService: IFileService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @ILabelService private readonly _labelService: ILabelService, + ) { + super(); + + // Register a single read-only filesystem provider for all remote agent + // hosts. Individual connections are identified by the URI authority. + this._fsProvider = this._register(new AgentHostFileSystemProvider()); + this._register(this._fileService.registerProvider(AGENT_HOST_SCHEME, this._fsProvider)); + + // Display agent-host URIs with the original file path + this._register(this._labelService.registerFormatter(AGENT_HOST_LABEL_FORMATTER)); + + // Reconcile when connections change (added/removed/reconnected) + this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._reconcileConnections(); + })); + + // Push auth token whenever the default account or sessions change + this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidChangeSessions(() => this._authenticateAllConnections())); + + // Initial setup for already-connected remotes + this._reconcileConnections(); + } + + private _reconcileConnections(): void { + const currentAddresses = new Set(this._remoteAgentHostService.connections.map(c => c.address)); + + // Remove connections no longer present + for (const [address] of this._connections) { + if (!currentAddresses.has(address)) { + this._logService.info(`[RemoteAgentHost] Removing contribution for ${address}`); + this._connections.deleteAndDispose(address); + } + } + + // Add or update connections + for (const connectionInfo of this._remoteAgentHostService.connections) { + const existing = this._connections.get(connectionInfo.address); + if (existing) { + // If the name changed, tear down and re-register with new name + if (existing.name !== connectionInfo.name) { + this._logService.info(`[RemoteAgentHost] Name changed for ${connectionInfo.address}: ${existing.name} -> ${connectionInfo.name}`); + this._connections.deleteAndDispose(connectionInfo.address); + this._setupConnection(connectionInfo.address, connectionInfo.name); + } + } else { + this._setupConnection(connectionInfo.address, connectionInfo.name); + } + } + } + + private _setupConnection(address: string, name: string | undefined): void { + const connection = this._remoteAgentHostService.getConnection(address); + if (!connection) { + return; + } + + const sanitized = agentHostAuthority(address); + const channelId = `agentHostIpc.remote.${sanitized}`; + const channelLabel = `Agent Host (${name || address})`; + const loggedConnection = this._instantiationService.createInstance(LoggingAgentConnection, connection, channelId, channelLabel); + const connState = new ConnectionState(connection.clientId, name, this._logService, loggedConnection); + this._connections.set(address, connState); + const store = connState.store; + + // Track authority -> connection mapping for FS provider routing + const authority = agentHostAuthority(address); + store.add(this._fsProvider.registerAuthority(authority, connection)); + + // Forward non-session actions to client state + store.add(loggedConnection.onDidAction(envelope => { + if (!isSessionAction(envelope.action)) { + connState.clientState.receiveEnvelope(envelope); + } + })); + + // Forward notifications to client state + store.add(loggedConnection.onDidNotification(n => { + connState.clientState.receiveNotification(n); + })); + + // React to root state changes (agent discovery) + store.add(connState.clientState.onDidChangeRootState(rootState => { + this._handleRootStateChange(address, loggedConnection, rootState); + })); + + // Subscribe to root state + loggedConnection.subscribe(URI.parse(ROOT_STATE_URI)).then(snapshot => { + if (store.isDisposed) { + return; + } + connState.clientState.handleSnapshot(ROOT_STATE_URI, snapshot.state, snapshot.fromSeq); + }).catch(err => { + this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); + loggedConnection.logError('subscribe(root)', err); + }); + + // Authenticate with this new connection + this._authenticateWithConnection(loggedConnection); + } + + private _handleRootStateChange(address: string, loggedConnection: LoggingAgentConnection, rootState: IRootState): void { + const connState = this._connections.get(address); + if (!connState) { + return; + } + + const incoming = new Set(rootState.agents.map(a => a.provider)); + + // Remove agents no longer present + for (const [provider] of connState.agents) { + if (!incoming.has(provider)) { + connState.agents.deleteAndDispose(provider); + connState.modelProviders.delete(provider); + } + } + + // Register new agents, push model updates to existing ones + for (const agent of rootState.agents) { + if (!connState.agents.has(agent.provider)) { + this._registerAgent(address, loggedConnection, agent, connState.name); + } else { + const modelProvider = connState.modelProviders.get(agent.provider); + modelProvider?.updateModels(agent.models); + } + } + } + + private _registerAgent(address: string, loggedConnection: LoggingAgentConnection, agent: IAgentInfo, configuredName: string | undefined): void { + // Only register copilot agents; other provider types are not supported + if (agent.provider !== 'copilot') { + this._logService.warn(`[RemoteAgentHost] Ignoring unsupported agent provider '${agent.provider}' from ${address}`); + return; + } + + const connState = this._connections.get(address); + if (!connState) { + return; + } + + const agentStore = new DisposableStore(); + connState.agents.set(agent.provider, agentStore); + connState.store.add(agentStore); + + const sanitized = agentHostAuthority(address); + const sessionType = `remote-${sanitized}-${agent.provider}`; + const agentId = sessionType; + const vendor = sessionType; + + const displayName = configuredName || `${agent.displayName} (${address})`; + + // Per-agent working directory cache, scoped to the agent store lifetime + const sessionWorkingDirs = new Map(); + agentStore.add(toDisposable(() => sessionWorkingDirs.clear())); + + // Capture the working directory from the active session for new sessions + const resolveWorkingDirectory = (resourceKey: string): string | undefined => { + const cached = sessionWorkingDirs.get(resourceKey); + if (cached) { + return cached; + } + const activeSession = this._sessionsManagementService.activeSession.get(); + const repoUri = activeSession?.workspace.get()?.repositories[0]?.uri; + if (repoUri) { + // The repository URI's path is the remote filesystem path + // (set via agentHostRemotePath in the folder picker callback) + const dir = repoUri.path; + sessionWorkingDirs.set(resourceKey, dir); + return dir; + } + return undefined; + }; + + // Chat session contribution + agentStore.add(this._chatSessionsService.registerChatSessionContribution({ + type: sessionType, + name: agentId, + displayName, + description: agent.description, + canDelegate: true, + requiresCustomModels: true, + supportsDelegation: false, + })); + + // Register as a sessions provider + const sessionsProvider = this._instantiationService.createInstance( + RemoteAgentHostSessionsProvider, address, configuredName, agent.provider); + agentStore.add(sessionsProvider); + agentStore.add(this._sessionsProvidersService.registerProvider(sessionsProvider)); + + // Session list controller (unified) + const listController = agentStore.add(this._instantiationService.createInstance( + AgentHostSessionListController, sessionType, agent.provider, loggedConnection, displayName)); + agentStore.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); + + // Session handler (unified) + const sessionHandler = agentStore.add(this._instantiationService.createInstance( + AgentHostSessionHandler, { + provider: agent.provider, + agentId, + sessionType, + fullName: displayName, + description: agent.description, + connection: loggedConnection, + connectionAuthority: sanitized, + extensionId: 'vscode.remote-agent-host', + extensionDisplayName: 'Remote Agent Host', + resolveWorkingDirectory, + resolveAuthentication: () => this._resolveAuthenticationInteractively(loggedConnection), + })); + agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); + + // Language model provider + const vendorDescriptor = { vendor, displayName, configuration: undefined, managementCommand: undefined, when: undefined }; + this._languageModelsService.deltaLanguageModelChatProviderDescriptors([vendorDescriptor], []); + agentStore.add(toDisposable(() => this._languageModelsService.deltaLanguageModelChatProviderDescriptors([], [vendorDescriptor]))); + const modelProvider = agentStore.add(new AgentHostLanguageModelProvider(sessionType, vendor)); + modelProvider.updateModels(agent.models); + connState.modelProviders.set(agent.provider, modelProvider); + agentStore.add(toDisposable(() => connState.modelProviders.delete(agent.provider))); + agentStore.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); + + this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); + } + + private _authenticateAllConnections(): void { + for (const [, connState] of this._connections) { + this._authenticateWithConnection(connState.loggedConnection); + } + } + + /** + * Discover auth requirements from the connection's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection): Promise { + try { + const metadata = await loggedConnection.getResourceMetadata(); + for (const resource of metadata.resources) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await loggedConnection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + loggedConnection.logError('authenticateWithConnection', err); + } + } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + return resolveTokenForResource(resourceServer, authorizationServers, scopes, this._authenticationService, this._logService, '[RemoteAgentHost]'); + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection): Promise { + try { + const metadata = await loggedConnection.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await loggedConnection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + loggedConnection.logError('resolveAuthenticationInteractively', err); + } + return false; + } +} + +registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + properties: { + [RemoteAgentHostsEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.remoteAgentHosts.enabled', "Enable connecting to remote agent hosts."), + default: false, + tags: ['experimental'], + }, + [RemoteAgentHostsSettingId]: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, + name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, + connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts new file mode 100644 index 0000000000000..f0ba5aa080c24 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostPicker.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IParsedRemoteAgentHostInput, IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; + +interface IRemoteAgentHostPickItem extends IQuickPickItem { + readonly remoteType: 'existing' | 'add'; + readonly address?: string; + readonly defaultDirectory?: string; +} + +const removeButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('removeRemote', "Remove Remote"), +}; + +/** + * Drives the "Browse Remotes" flow: lets the user pick an existing configured + * remote or add a new one, then opens a folder-picker on that remote. + * + * Returns the selected folder URI, or `undefined` if the user cancelled. + */ +export async function pickRemoteAgentHostFolder( + accessor: ServicesAccessor, +): Promise { + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const quickInputService = accessor.get(IQuickInputService); + const fileDialogService = accessor.get(IFileDialogService); + const notificationService = accessor.get(INotificationService); + + let selectedAddress: string | undefined; + let selectedName: string | undefined; + let defaultDirectory: string | undefined; + + const configuredEntries = remoteAgentHostService.configuredEntries; + if (configuredEntries.length > 0) { + const picks: IRemoteAgentHostPickItem[] = configuredEntries.map(entry => { + const connection = remoteAgentHostService.connections.find(c => c.address === entry.address); + return { + remoteType: 'existing' as const, + label: entry.name, + description: entry.address, + address: entry.address, + defaultDirectory: connection?.defaultDirectory, + buttons: [removeButton], + }; + }); + picks.push({ + remoteType: 'add', + label: localize('addRemote', "Add Remote..."), + description: localize('addRemoteDescription', "Connect to a new remote agent host"), + }); + + const picked = await quickInputService.pick(picks, { + title: localize('selectRemote', "Select Remote"), + placeHolder: localize('selectRemotePlaceholder', "Choose a remote agent host or add a new one"), + matchOnDescription: true, + onDidTriggerItemButton: async context => { + if (context.button === removeButton && context.item.address) { + try { + await remoteAgentHostService.removeRemoteAgentHost(context.item.address); + context.removeItem(); + } catch { + notificationService.error(localize('removeRemoteFailed', "Failed to remove remote agent host {0}.", context.item.address)); + } + } + }, + }); + if (!picked) { + return undefined; + } + + if (picked.remoteType === 'existing') { + const configuredEntry = configuredEntries.find(e => e.address === picked.address); + if (!configuredEntry) { + return undefined; + } + try { + const connection = await remoteAgentHostService.addRemoteAgentHost(configuredEntry); + selectedAddress = connection.address; + selectedName = connection.name; + defaultDirectory = connection.defaultDirectory; + } catch { + notificationService.error(localize('connectRemoteFailed', "Failed to connect to remote agent host {0}.", configuredEntry.address)); + return undefined; + } + } else { + const addedRemote = await promptToAddRemoteAgentHost(remoteAgentHostService, quickInputService, notificationService); + if (!addedRemote) { + return undefined; + } + selectedAddress = addedRemote.address; + selectedName = addedRemote.name; + defaultDirectory = addedRemote.defaultDirectory; + } + } else { + const addedRemote = await promptToAddRemoteAgentHost(remoteAgentHostService, quickInputService, notificationService); + if (!addedRemote) { + return undefined; + } + selectedAddress = addedRemote.address; + selectedName = addedRemote.name; + defaultDirectory = addedRemote.defaultDirectory; + } + + if (!selectedAddress || !selectedName) { + return undefined; + } + + return pickFolderOnRemote(selectedAddress, selectedName, defaultDirectory, fileDialogService); +} + +async function promptToAddRemoteAgentHost( + remoteAgentHostService: IRemoteAgentHostService, + quickInputService: IQuickInputService, + notificationService: INotificationService, +): Promise<{ readonly address: string; readonly name: string; readonly defaultDirectory?: string } | undefined> { + const parsed = await promptForRemoteAddress(quickInputService); + if (!parsed) { + return undefined; + } + + const name = await promptForRemoteName(quickInputService, parsed.suggestedName); + if (!name) { + return undefined; + } + + try { + const connection = await remoteAgentHostService.addRemoteAgentHost({ + address: parsed.address, + name, + connectionToken: parsed.connectionToken, + }); + return { + address: connection.address, + name: connection.name, + defaultDirectory: connection.defaultDirectory, + }; + } catch { + notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.address)); + return undefined; + } +} + +async function promptForRemoteAddress(quickInputService: IQuickInputService): Promise { + const value = await quickInputService.input({ + title: localize('addRemoteTitle', "Add Remote"), + prompt: localize('addRemotePrompt', "Paste a host, host:port, or WebSocket URL. Example: {0}", 'ws://127.0.0.1:8089'), + placeHolder: 'ws://127.0.0.1:8080?tkn=abc-123', + ignoreFocusLost: true, + validateInput: async value => { + const result = parseRemoteAgentHostInput(value); + if (result.error === RemoteAgentHostInputValidationError.Empty) { + return localize('addRemoteValidationEmpty', "Enter a remote agent host address."); + } + if (result.error === RemoteAgentHostInputValidationError.Invalid) { + return localize('addRemoteValidationInvalid', "Enter a valid host, host:port, or WebSocket URL."); + } + return undefined; + }, + }); + if (!value) { + return undefined; + } + const result = parseRemoteAgentHostInput(value); + return result.parsed; +} + +async function promptForRemoteName(quickInputService: IQuickInputService, defaultName: string): Promise { + const value = await quickInputService.input({ + title: localize('nameRemoteTitle', "Name Remote"), + prompt: localize('nameRemotePrompt', "Enter a display name for this remote agent host."), + placeHolder: localize('nameRemotePlaceholder', "My Remote"), + value: defaultName, + valueSelection: [0, defaultName.length], + ignoreFocusLost: true, + validateInput: async value => value.trim() ? undefined : localize('nameRemoteValidationEmpty', "Enter a name for this remote agent host."), + }); + return value?.trim() || undefined; +} + +async function pickFolderOnRemote( + selectedAddress: string, + selectedName: string, + defaultDirectory: string | undefined, + fileDialogService: IFileDialogService, +): Promise { + const authority = agentHostAuthority(selectedAddress); + const defaultUri = defaultDirectory + ? agentHostUri(authority, defaultDirectory) + : agentHostUri(authority, '/'); + + try { + const selected = await fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), + availableFileSystems: [AGENT_HOST_SCHEME], + defaultUri, + }); + return selected?.[0]; + } catch { + // dialog was cancelled or failed + return undefined; + } +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts new file mode 100644 index 0000000000000..47280c291fbf3 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ISessionData, ISessionWorkspace, SessionStatus } from '../../sessions/common/sessionData.js'; +import { ISendRequestOptions, ISessionsBrowseAction, ISessionsChangeEvent, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { IChatSessionFileChange } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; + +const CopilotCLISessionType: ISessionType = { + id: AgentSessionProviders.AgentHostCopilot, + label: localize('copilotCLI', "Copilot"), + icon: Codicon.copilot, + requiresWorkspaceTrust: true, +}; + +/** + * A sessions provider for a single agent on a remote agent host connection. + * One instance is created per agent discovered on a connection. + */ +export class RemoteAgentHostSessionsProvider extends Disposable implements ISessionsProvider { + + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon = Codicon.remote; + readonly sessionTypes: readonly ISessionType[]; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + readonly browseActions: readonly ISessionsBrowseAction[]; + + constructor( + private readonly _address: string, + _connectionName: string | undefined, + _agentProvider: string, + @IFileDialogService private readonly _fileDialogService: IFileDialogService, + ) { + super(); + + const sanitized = agentHostAuthority(_address); + const displayName = _connectionName || `${_agentProvider} (${_address})`; + + this.id = `agenthost-${sanitized}-${_agentProvider}`; + this.label = displayName; + + this.sessionTypes = [CopilotCLISessionType]; + + this.browseActions = [{ + label: localize('browseRemote', "Browse Remote Folders..."), + icon: Codicon.remote, + providerId: this.id, + execute: () => this._browseForFolder(), + }]; + } + + // -- Workspaces -- + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + return { + label: repositoryUri.path.split('/').pop() || repositoryUri.path, + icon: Codicon.remote, + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: this.label, baseBranchProtected: undefined }], + }; + } + + // -- Sessions -- + + getSessionTypes(_session: ISessionData): ISessionType[] { + return [...this.sessionTypes]; + } + + getSessions(): ISessionData[] { + // Sessions are managed by the existing AgentHostSessionListController + // This will be populated when the list controller is integrated + return []; + } + + // -- Session Lifecycle -- + + createNewSession(workspace: ISessionWorkspace): ISessionData { + const workspaceUri = workspace.repositories[0]?.uri; + if (!workspaceUri) { + throw new Error('Workspace has no repository URI'); + } + const resource = URI.from({ scheme: this.sessionTypes[0]?.id ?? 'agenthost', path: `/untitled-${generateUuid()}` }); + // Create a minimal ISessionData for the new session + return { + sessionId: `${this.id}:${resource.toString()}`, + resource, + providerId: this.id, + sessionType: this.sessionTypes[0]?.id ?? 'agenthost', + icon: Codicon.remote, + createdAt: new Date(), + workspace: observableValue(this, { + label: workspaceUri.path.split('/').pop() || workspaceUri.path, + icon: Codicon.remote, + repositories: [{ uri: workspaceUri, workingDirectory: undefined, detail: this.label, baseBranchProtected: undefined }], + }), + title: observableValue(this, ''), + updatedAt: observableValue(this, new Date()), + status: observableValue(this, SessionStatus.Untitled), + changes: observableValue(this, []), + modelId: observableValue(this, undefined), + mode: observableValue(this, undefined), + loading: observableValue(this, false), + isArchived: observableValue(this, false), + isRead: observableValue(this, true), + description: observableValue(this, undefined), + lastTurnEnd: observableValue(this, undefined), + pullRequestUri: observableValue(this, undefined), + }; + } + + setSessionType(_sessionId: string, _type: ISessionType): ISessionData { + throw new Error('Remote agent host sessions do not support changing session type'); + } + + setModel(_sessionId: string, _modelId: string): void { + // No-op for remote agent host sessions + } + + // -- Session Actions -- + + async archiveSession(_sessionId: string): Promise { + // Agent host sessions don't support archiving + } + + async unarchiveSession(_sessionId: string): Promise { + // Agent host sessions don't support unarchiving + } + + async deleteSession(_sessionId: string): Promise { + // Agent host sessions don't support deletion + } + + async renameSession(_sessionId: string, _title: string): Promise { + // Agent host sessions don't support renaming + } + + setRead(_sessionId: string, _read: boolean): void { + // Agent host sessions don't track read state + } + + async sendRequest(_sessionId: string, sendOptions: ISendRequestOptions): Promise { + // Agent host session send is handled separately + throw new Error('Remote agent host sessions do not support sending requests through the sessions provider'); + } + + // -- Private -- + + private async _browseForFolder(): Promise { + const authority = agentHostAuthority(this._address); + const defaultUri = agentHostUri(authority, '/'); + + try { + const selected = await this._fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", this.label), + availableFileSystems: [AGENT_HOST_SCHEME], + defaultUri, + }); + if (selected?.[0]) { + const uri = selected[0]; + const label = uri.path.split('/').pop() || uri.path; + return { + label, + icon: Codicon.remote, + repositories: [{ uri, workingDirectory: undefined, detail: this.label, baseBranchProtected: undefined }], + }; + } + } catch { + // dialog was cancelled or failed + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts new file mode 100644 index 0000000000000..fe6f55d5fa0b5 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; + +const $ = DOM.$; + +const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; + +export interface IAICustomizationShortcutsWidgetOptions { + readonly onDidToggleCollapse?: () => void; +} + +export class AICustomizationShortcutsWidget extends Disposable { + + constructor( + container: HTMLElement, + options: IAICustomizationShortcutsWidgetOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService private readonly storageService: IStorageService, + @IPromptsService private readonly promptsService: IPromptsService, + @IMcpService private readonly mcpService: IMcpService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + ) { + super(); + + this._render(container, options); + } + + private _render(parent: HTMLElement, options: IAICustomizationShortcutsWidgetOptions | undefined): void { + // Get initial collapsed state + const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + + const container = DOM.append(parent, $('.ai-customization-toolbar')); + if (isCollapsed) { + container.classList.add('collapsed'); + } + + // Header (clickable to toggle) + const header = DOM.append(container, $('.ai-customization-header')); + header.classList.toggle('collapsed', isCollapsed); + + const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); + const headerButton = this._register(new Button(headerButtonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); + headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); + headerButton.label = localize('customizations', "Customizations"); + + const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); + const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); + const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Toolbar container + const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { + const requestId = ++updateCountRequestId; + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService); + if (requestId !== updateCountRequestId) { + return; + } + + headerTotalCount.classList.toggle('hidden', totalCount === 0); + headerTotalCount.textContent = `${totalCount}`; + }; + + this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); + this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + updateHeaderTotalCount(); + })); + this._register(autorun(reader => { + this.workspaceService.activeProjectRoot.read(reader); + updateHeaderTotalCount(); + })); + updateHeaderTotalCount(); + + // Toggle collapse on header click + const transitionListener = this._register(new MutableDisposable()); + const toggleCollapse = () => { + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); + headerButton.element.setAttribute('aria-expanded', String(!collapsed)); + chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Re-layout after the transition + transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { + transitionListener.clear(); + options?.onDidToggleCollapse?.(); + }); + }; + + this._register(headerButton.onDidClick(() => toggleCollapse())); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index dd874c3e86c71..88d932c7ee5d9 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -4,58 +4,151 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { isEqualOrParent } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; export interface ISourceCounts { readonly workspace: number; readonly user: number; readonly extension: number; + readonly builtin: number; } -export function getSourceCountsTotal(counts: ISourceCounts): number { - return counts.workspace + counts.user + counts.extension; -} +const storageToCountKey: Partial> = { + [PromptsStorage.local]: 'workspace', + [PromptsStorage.user]: 'user', + [PromptsStorage.extension]: 'extension', + [BUILTIN_STORAGE]: 'builtin', +}; -export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { - const [workspaceItems, userItems, extensionItems] = await Promise.all([ - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), - promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), - ]); - return { - workspace: workspaceItems.length, - user: userItems.length, - extension: extensionItems.length, - }; +export function getSourceCountsTotal(counts: ISourceCounts, filter: IStorageSourceFilter): number { + let total = 0; + for (const storage of filter.sources) { + const key = storageToCountKey[storage]; + if (key) { + total += counts[key]; + } + } + return total; } -export async function getSkillSourceCounts(promptsService: IPromptsService): Promise { - const skills = await promptsService.findAgentSkills(CancellationToken.None); - if (!skills || skills.length === 0) { - return { workspace: 0, user: 0, extension: 0 }; +/** + * Gets source counts for a prompt type, using the SAME data sources as + * loadItems() in the list widget to avoid count mismatches. + */ +export async function getSourceCounts( + promptsService: IPromptsService, + promptType: PromptsType, + filter: IStorageSourceFilter, + workspaceContextService: IWorkspaceContextService, + workspaceService: IAICustomizationWorkspaceService, + fileService?: IFileService, +): Promise { + const items: { storage: PromptsStorage; uri: URI }[] = []; + + if (promptType === PromptsType.agent) { + // Must match loadItems: uses getCustomAgents() + const agents = await promptsService.getCustomAgents(CancellationToken.None); + for (const a of agents) { + items.push({ storage: a.source.storage, uri: a.uri }); + } + } else if (promptType === PromptsType.skill) { + // Must match loadItems: uses findAgentSkills() + const skills = await promptsService.findAgentSkills(CancellationToken.None); + for (const s of skills ?? []) { + items.push({ storage: s.storage, uri: s.uri }); + } + } else if (promptType === PromptsType.prompt) { + // Must match loadItems: uses getPromptSlashCommands() filtering out skills + const commands = await promptsService.getPromptSlashCommands(CancellationToken.None); + for (const c of commands) { + if (c.promptPath.type === PromptsType.skill) { + continue; + } + items.push({ storage: c.promptPath.storage, uri: c.promptPath.uri }); + } + } else if (promptType === PromptsType.instructions) { + // Must match loadItems: uses listPromptFiles + listAgentInstructions + const promptFiles = await promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const f of promptFiles) { + items.push({ storage: f.storage, uri: f.uri }); + } + const agentInstructions = await promptsService.listAgentInstructions(CancellationToken.None, undefined); + const workspaceFolderUris = workspaceContextService.getWorkspace().folders.map(f => f.uri); + const activeRoot = workspaceService.getActiveProjectRoot(); + if (activeRoot) { + workspaceFolderUris.push(activeRoot); + } + for (const file of agentInstructions) { + const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); + items.push({ + storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, + uri: file.uri, + }); + } + } else if (promptType === PromptsType.hook && fileService) { + // Must match loadItems: parse individual hooks from each file + const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + const activeRoot = workspaceService.getActiveProjectRoot(); + for (const hookFile of hookFiles) { + try { + const content = await fileService.readFile(hookFile.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, ''); + if (hooks.size > 0) { + for (const [, entry] of hooks) { + for (let i = 0; i < entry.hooks.length; i++) { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } + } else { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } catch { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } + } else { + // hooks and anything else: uses listPromptFiles + const files = await promptsService.listPromptFiles(promptType, CancellationToken.None); + for (const f of files) { + items.push({ storage: f.storage, uri: f.uri }); + } } + + // Apply the same storage source filter as the list widget + const filtered = applyStorageSourceFilter(items, filter); return { - workspace: skills.filter(s => s.storage === PromptsStorage.local).length, - user: skills.filter(s => s.storage === PromptsStorage.user).length, - extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + workspace: filtered.filter(i => i.storage === PromptsStorage.local).length, + user: filtered.filter(i => i.storage === PromptsStorage.user).length, + extension: filtered.filter(i => i.storage === PromptsStorage.extension).length, + builtin: filtered.filter(i => i.storage === BUILTIN_STORAGE).length, }; } -export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { - const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ - getPromptSourceCounts(promptsService, PromptsType.agent), - getSkillSourceCounts(promptsService), - getPromptSourceCounts(promptsService, PromptsType.instructions), - getPromptSourceCounts(promptsService, PromptsType.prompt), - getPromptSourceCounts(promptsService, PromptsType.hook), - ]); - - return getSourceCountsTotal(agentCounts) - + getSourceCountsTotal(skillCounts) - + getSourceCountsTotal(instructionCounts) - + getSourceCountsTotal(promptCounts) - + getSourceCountsTotal(hookCounts) - + mcpService.servers.get().length; +export async function getCustomizationTotalCount( + promptsService: IPromptsService, + mcpService: IMcpService, + workspaceService: IAICustomizationWorkspaceService, + workspaceContextService: IWorkspaceContextService, + agentPluginService?: IAgentPluginService, +): Promise { + const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.prompt, PromptsType.hook]; + const results = await Promise.all(types.map(type => { + const filter = workspaceService.getStorageSourceFilter(type); + return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) + .then(counts => getSourceCountsTotal(counts, filter)); + })); + const pluginCount = agentPluginService?.plugins.get().length ?? 0; + return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length + pluginCount; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index f2e6d9e45e71b..d8c4b413b2caa 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -5,7 +5,6 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; -import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -21,76 +20,80 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Menus } from '../../../browser/menus.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, hookIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -interface ICustomizationItemConfig { +export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; readonly section: AICustomizationManagementSection; - readonly getSourceCounts?: (promptsService: IPromptsService) => Promise; - readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; + readonly promptType?: PromptsType; + readonly isMcp?: boolean; + readonly isPlugins?: boolean; } -const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ +export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ { id: 'sessions.customization.agents', label: localize('agents', "Agents"), icon: agentIcon, section: AICustomizationManagementSection.Agents, - getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.agent), + promptType: PromptsType.agent, }, { id: 'sessions.customization.skills', label: localize('skills', "Skills"), icon: skillIcon, section: AICustomizationManagementSection.Skills, - getSourceCounts: (ps) => getSkillSourceCounts(ps), + promptType: PromptsType.skill, }, { id: 'sessions.customization.instructions', label: localize('instructions', "Instructions"), icon: instructionsIcon, section: AICustomizationManagementSection.Instructions, - getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.instructions), + promptType: PromptsType.instructions, }, { id: 'sessions.customization.prompts', label: localize('prompts', "Prompts"), icon: promptIcon, section: AICustomizationManagementSection.Prompts, - getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.prompt), + promptType: PromptsType.prompt, }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), icon: hookIcon, section: AICustomizationManagementSection.Hooks, - getSourceCounts: (ps) => getPromptSourceCounts(ps, PromptsType.hook), + promptType: PromptsType.hook, }, { id: 'sessions.customization.mcpServers', label: localize('mcpServers', "MCP Servers"), - icon: Codicon.server, + icon: mcpServerIcon, section: AICustomizationManagementSection.McpServers, - getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), + isMcp: true, }, { - id: 'sessions.customization.models', - label: localize('models', "Models"), - icon: Codicon.vm, - section: AICustomizationManagementSection.Models, - getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), + id: 'sessions.customization.plugins', + label: localize('plugins', "Plugins"), + icon: pluginIcon, + section: AICustomizationManagementSection.Plugins, + isPlugins: true, }, ]; @@ -98,7 +101,7 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ * Custom ActionViewItem for each customization link in the toolbar. * Renders icon + label + source count badges, matching the sidebar footer style. */ -class CustomizationLinkViewItem extends ActionViewItem { +export class CustomizationLinkViewItem extends ActionViewItem { private readonly _viewItemDisposables: DisposableStore; private _button: Button | undefined; @@ -113,6 +116,9 @@ class CustomizationLinkViewItem extends ActionViewItem { @IMcpService private readonly _mcpService: IMcpService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -156,6 +162,10 @@ class CustomizationLinkViewItem extends ActionViewItem { this._mcpService.servers.read(reader); this._updateCounts(); })); + this._viewItemDisposables.add(autorun(reader => { + this._agentPluginService.plugins.read(reader); + this._updateCounts(); + })); this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); this._viewItemDisposables.add(autorun(reader => { this._activeSessionService.activeSession.read(reader); @@ -166,48 +176,34 @@ class CustomizationLinkViewItem extends ActionViewItem { this._updateCounts(); } + private _updateCountsRequestId = 0; + private async _updateCounts(): Promise { if (!this._countContainer) { return; } - if (this._config.getSourceCounts) { - const counts = await this._config.getSourceCounts(this._promptsService); - this._renderSourceCounts(this._countContainer, counts); - } else if (this._config.getCount) { - const count = await this._config.getCount(this._languageModelsService, this._mcpService); - this._renderSimpleCount(this._countContainer, count); - } - } + const requestId = ++this._updateCountsRequestId; - private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { - container.textContent = ''; - const total = getSourceCountsTotal(counts); - container.classList.toggle('hidden', total === 0); - if (total === 0) { - return; - } - - const sources: { count: number; icon: ThemeIcon; title: string }[] = [ - { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, - { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, - ]; - - for (const source of sources) { - if (source.count === 0) { - continue; + if (this._config.promptType) { + const type = this._config.promptType; + const filter = this._workspaceService.getStorageSourceFilter(type); + const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); + if (requestId !== this._updateCountsRequestId) { + return; } - const badge = append(container, $('span.source-count-badge')); - badge.title = source.title; - const icon = append(badge, $('span.source-count-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); - const num = append(badge, $('span.source-count-num')); - num.textContent = `${source.count}`; + const total = getSourceCountsTotal(counts, filter); + this._renderTotalCount(this._countContainer, total); + } else if (this._config.isMcp) { + const total = this._mcpService.servers.get().length; + this._renderTotalCount(this._countContainer, total); + } else if (this._config.isPlugins) { + const total = this._agentPluginService.plugins.get().length; + this._renderTotalCount(this._countContainer, total); } } - private _renderSimpleCount(container: HTMLElement, count: number): void { + private _renderTotalCount(container: HTMLElement, count: number): void { container.textContent = ''; container.classList.toggle('hidden', count === 0); if (count > 0) { @@ -220,7 +216,7 @@ class CustomizationLinkViewItem extends ActionViewItem { // --- Register actions and view items --- // -class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { +export class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; diff --git a/src/vs/sessions/contrib/sessions/browser/defaultCopilotSessionsProvider.ts b/src/vs/sessions/contrib/sessions/browser/defaultCopilotSessionsProvider.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/defaultCopilotSessionsProvider.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd57c..5525dc5a75818 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -2,132 +2,135 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* AI Customization section - pinned to bottom */ +.ai-customization-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-panel-border, transparent); + margin: 0 12px; + padding: 6px 0; +} + +/* Make the toolbar, action bar, and items fill full width and stack vertically */ +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; +} + +.ai-customization-toolbar .customization-link-widget { + width: 100%; +} + +/* Customization header - clickable for collapse */ +.ai-customization-toolbar .ai-customization-header { + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) { + margin-bottom: 4px; +} + +.ai-customization-toolbar .ai-customization-chevron { + flex-shrink: 0; + opacity: 0; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { + opacity: 0.7; +} + +.ai-customization-toolbar .ai-customization-header-total { + display: none; + opacity: 0.7; + font-size: 11px; + line-height: 1; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { + display: inline; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { + display: none; +} + +/* Button container - fills available space */ +.ai-customization-toolbar .customization-link-button-container { + overflow: hidden; + min-width: 0; + flex: 1; +} + +/* Button needs relative positioning for counts overlay */ +.ai-customization-toolbar .customization-link-button { + position: relative; +} + +/* Customizations header label uses heavier weight */ +.ai-customization-toolbar .ai-customization-header .customization-link-button { + font-weight: 500; +} + +/* Counts - floating right inside the button */ +.ai-customization-toolbar .customization-link-counts { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; +} + +.ai-customization-toolbar .customization-link-counts.hidden { + display: none; +} + +.ai-customization-toolbar .source-count-badge { + display: flex; + align-items: center; + gap: 2px; +} + +.ai-customization-toolbar .source-count-icon { + font-size: 12px; + opacity: 0.6; +} + +.ai-customization-toolbar .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} + +/* Collapsed state */ +.ai-customization-toolbar .ai-customization-toolbar-content { + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + padding-bottom: 2px; +} -.agent-sessions-viewpane { - - /* AI Customization section - pinned to bottom */ - .ai-customization-toolbar { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - padding: 6px; - } - - /* Make the toolbar, action bar, and items fill full width and stack vertically */ - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { - display: flex; - flex-direction: column; - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { - width: 100%; - max-width: 100%; - } - - .ai-customization-toolbar .customization-link-widget { - width: 100%; - } - - /* Customization header - clickable for collapse */ - .ai-customization-toolbar .ai-customization-header { - display: flex; - align-items: center; - -webkit-user-select: none; - user-select: none; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) { - margin-bottom: 4px; - } - - .ai-customization-toolbar .ai-customization-chevron { - flex-shrink: 0; - opacity: 0; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { - opacity: 0.7; - } - - .ai-customization-toolbar .ai-customization-header-total { - display: none; - opacity: 0.7; - font-size: 11px; - line-height: 1; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { - display: inline; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { - display: none; - } - - /* Button container - fills available space */ - .ai-customization-toolbar .customization-link-button-container { - overflow: hidden; - min-width: 0; - flex: 1; - } - - /* Button needs relative positioning for counts overlay */ - .ai-customization-toolbar .customization-link-button { - position: relative; - } - - /* Counts - floating right inside the button */ - .ai-customization-toolbar .customization-link-counts { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - display: flex; - align-items: center; - gap: 6px; - } - - .ai-customization-toolbar .customization-link-counts.hidden { - display: none; - } - - .ai-customization-toolbar .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-toolbar .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-toolbar .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - - /* Collapsed state */ - .ai-customization-toolbar .ai-customization-toolbar-content { - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { - max-height: 0; - } +.ai-customization-toolbar.collapsed .ai-customization-toolbar-content { + max-height: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css new file mode 100644 index 0000000000000..490b929758f68 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -0,0 +1,350 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-list-control { + flex: 1 1 auto; + height: 100%; + min-height: 0; + + .pane-body & .monaco-scrollable-element { + padding: 0 6px; + } + + .monaco-list-row { + border-radius: 6px; + } + + .monaco-list-row .force-no-twistie { + display: none !important; + } + + .monaco-list-row.selected .session-details-row { + color: unset; + } + + .monaco-list:focus .monaco-list-row.selected .session-details-row { + .session-diff { + + .session-diff-added, + .session-diff-removed { + color: unset; + } + } + } + + .monaco-list-row.selected .session-title { + color: unset; + } + + .monaco-list:not(:focus) .monaco-list-row.selected .session-details-row { + color: var(--vscode-descriptionForeground); + } + + .monaco-list-row .session-title-toolbar { + position: relative; + height: 16px; + display: none; + + .monaco-toolbar { + position: relative; + right: 0; + top: 0; + } + } + + .monaco-list-row:hover, + .monaco-list-row.focused:not(.selected) { + .session-title-toolbar { + display: block; + } + + .session-title { + margin-right: 8px; + } + } +} + +/* Session Item */ + +.session-item { + display: flex; + flex-direction: row; + height: 100%; + box-sizing: border-box; + padding: 8px 6px; + + &.archived { + color: var(--vscode-descriptionForeground); + } + + .session-main, + .session-title-row, + .session-details-row { + flex: 1; + min-width: 0; + } + + .session-icon { + display: flex; + align-items: flex-start; + line-height: 17px; + + > .codicon { + flex-shrink: 0; + font-size: 12px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + } + + &.session-icon-active > .codicon { + color: var(--vscode-textLink-foreground); + } + + &.session-icon-pulse > .codicon { + color: var(--vscode-list-warningForeground); + animation: session-needs-input-pulse 2s ease-in-out infinite; + } + + @media (prefers-reduced-motion: reduce) { + &.session-icon-pulse > .codicon { + animation: none; + } + } + + &.session-icon-unread > .codicon { + color: var(--vscode-textLink-foreground); + } + + &.session-icon-error > .codicon { + color: var(--vscode-errorForeground); + } + + &.session-icon-pr > .codicon { + color: var(--vscode-charts-green); + } + + /* Small dot for read sessions — subtle indicator */ + > .codicon.codicon-circle-small-filled { + color: var(--vscode-agentSessionReadIndicator-foreground); + } + } + + .session-main { + padding-left: 6px; + } + + .session-title-row, + .session-details-row { + display: flex; + align-items: center; + } + + .session-title-row { + line-height: 17px; + padding-bottom: 4px; + } + + .session-details-row { + gap: 4px; + font-size: 12px; + line-height: 15px; + max-height: 15px; + color: var(--vscode-descriptionForeground); + + .session-details-icon { + display: flex; + align-items: center; + + > .codicon { + font-size: 12px; + } + } + + .session-diff { + font-variant-numeric: tabular-nums; + overflow: hidden; + display: flex; + gap: 4px; + + .session-diff-added { + color: var(--vscode-chat-linesAddedForeground); + } + + .session-diff-removed { + color: var(--vscode-chat-linesRemovedForeground); + } + } + + .session-badge { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .session-separator { + display: none; + + &.has-separator { + display: inline; + + &::before { + content: '\00B7'; + } + } + } + } + + .session-title, + .session-description { + /* push other items to the end */ + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .session-description:empty { + display: none; + } + + .session-title { + font-size: 13px; + } + + .session-time { + display: flex; + align-items: center; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .session-approval-row { + display: none; + align-items: flex-end; + gap: 8px; + margin-top: 4px; + padding: 4px 8px; + box-sizing: border-box; + border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent)); + border-radius: 4px; + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + + &.visible { + display: flex; + } + + .session-approval-label { + flex: 1; + overflow: hidden; + min-width: 0; + + & > .rendered-markdown, + & > .rendered-markdown > .code, + & > .rendered-markdown > .code > span { + display: block; + overflow: hidden; + } + + .monaco-tokenized-source { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + } + } + + .session-approval-button { + flex-shrink: 0; + + .monaco-button { + padding: 2px 10px; + font-size: 12px; + white-space: nowrap; + } + } + } +} + +/* Show More */ + +.session-show-more { + display: flex; + justify-content: center; + align-items: center; + padding: 0 6px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + min-height: 26px; + cursor: pointer; + + .session-show-more-label { + padding: 0 6px; + flex-shrink: 0; + white-space: nowrap; + } + + /* Lines on both sides of the text */ + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-widget-border, var(--vscode-contrastBorder)); + } +} + +.monaco-list-row:hover .session-show-more:hover { + color: var(--vscode-foreground); +} + +/* Section Header */ + +.session-section { + display: flex; + align-items: center; + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + /* align with session item padding */ + padding: 0 6px; + + .session-section-label { + flex: 1; + } + + .session-section-count { + opacity: 0.7; + margin-right: 4px; + } +} + +.sessions-list-control { + + .monaco-list:focus .monaco-list-row.focused.selected .session-section-label, + .monaco-list:focus .monaco-list-row.selected .session-section-label { + color: var(--vscode-list-activeSelectionForeground); + } + + .monaco-list:not(:focus) .monaco-list-row.selected .session-section-label { + color: var(--vscode-list-inactiveSelectionForeground); + } +} + +@keyframes session-needs-input-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index f8b2715466dbc..a8d1a3310c381 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -6,24 +6,32 @@ /* Container - button style hover */ .command-center .agent-sessions-titlebar-container { display: flex; - width: 38vw; - max-width: 600px; - display: flex; + width: 100%; flex-direction: row; flex-wrap: nowrap; align-items: center; - justify-content: center; - padding: 0 10px; + justify-content: flex-start; + padding-left: 16px; height: 22px; border-radius: 4px; - cursor: pointer; -webkit-app-region: no-drag; overflow: hidden; color: var(--vscode-commandCenter-foreground); gap: 6px; + cursor: default; } -.command-center .agent-sessions-titlebar-container:hover { +/* Session pill - clickable area for session picker */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill { + display: flex; + align-items: center; + padding: 0 4px; + border-radius: 4px; + min-width: 0; + max-width: 600px; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill:hover { background-color: var(--vscode-toolbar-hoverBackground); } @@ -32,16 +40,15 @@ outline-offset: -1px; } -/* Center group: icon + label + folder + changes */ +/* Center group: icon + label + folder */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { display: flex; align-items: center; gap: 6px; - overflow: hidden; - justify-content: center; + min-width: 0; + justify-content: flex-start; + cursor: pointer; } - -/* Kind icon */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { display: flex; align-items: center; @@ -70,34 +77,50 @@ flex-shrink: 0; } -/* Changes container */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { +/* Provider label (shown for untitled sessions) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-provider { display: flex; align-items: center; - gap: 3px; - cursor: pointer; - padding: 0 4px; - border-radius: 3px; + flex-shrink: 0; + opacity: 0.7; } -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes:hover { - background-color: var(--vscode-toolbar-hoverBackground); +/* Provider label (shown for untitled sessions) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-provider { + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.7; } -/* Changes icon */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-icon { +/* Session count widget (tasklist icon + unread count, left of pill) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-count { display: flex; align-items: center; + gap: 3px; flex-shrink: 0; - font-size: 14px; + cursor: pointer; + padding: 0 4px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + outline: none; } -/* Insertions */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-added { - color: var(--vscode-gitDecoration-addedResourceForeground); +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-count:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-count .codicon { + font-size: 16px; } -/* Deletions */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-removed { - color: var(--vscode-gitDecoration-deletedResourceForeground); +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-count-label { + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 16px; } + diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be701275e..f2f6f71e1be29 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -40,6 +40,7 @@ flex: 1; overflow: hidden; min-height: 0; + margin-bottom: 12px; } .agent-sessions-header { @@ -73,7 +74,7 @@ } .agent-sessions-new-button-container { - padding: 6px 12px 8px 12px; + padding: 8px 12px; } .agent-sessions-new-button-container .monaco-button { @@ -93,10 +94,46 @@ .agent-sessions-control-container { flex: 1; overflow: hidden; + } +} + +/* Sessions-app-specific overrides for the agent sessions viewer. + * These styles only apply within the agent-sessions-workbench context. */ + +.agent-sessions-workbench { + + /* + * Show-more / show-less: content always rendered, list row height + * controls visibility (1px = clipped, 26px = visible). + * Height animation is driven by JS (requestAnimationFrame) since + * the virtualized list uses absolute positioning. + */ + .agent-session-show-more { + justify-content: center; + align-items: center; + padding: 0 6px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + min-height: 26px; + + .agent-session-show-more-label { + padding: 0 6px; + flex-shrink: 0; + white-space: nowrap; + } - /* Override section header padding to align with dot indicators */ - .agent-session-section { - padding-left: 12px; + /* Lines on both sides of the text as flex items */ + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-widget-border, var(--vscode-contrastBorder)); } } + + /* Brighter text on direct hover */ + .monaco-list-row:hover .agent-session-show-more:hover { + color: var(--vscode-foreground); + } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index b81e8fa62848f..25e9f725a62b6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -12,10 +12,10 @@ import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js' import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; -import { SessionsAuxiliaryBarContribution } from './sessionsAuxiliaryBarContribution.js'; -import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; +import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; +import { SessionsView, SessionsViewId } from './views/sessionsView.js'; +import './views/sessionsViewActions.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -32,21 +32,22 @@ const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); + +registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionsAuxiliaryBarContribution.ID, SessionsAuxiliaryBarContribution, WorkbenchPhase.AfterRestored); registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts deleted file mode 100644 index eb36b915ad62d..0000000000000 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ /dev/null @@ -1,117 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { autorun, derivedOpts, IReader } from '../../../../base/common/observable.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; -import { isEqual } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { ISessionsManagementService } from './sessionsManagementService.js'; - -interface IPendingTurnState { - readonly hadChangesBeforeSend: boolean; - readonly submittedAt: number; -} - -export class SessionsAuxiliaryBarContribution extends Disposable { - - static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; - - private readonly pendingTurnStateByResource = new ResourceMap(); - - constructor( - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @IChatService private readonly chatService: IChatService, - ) { - super(); - - const activeSessionResourceObs = derivedOpts({ - equalsFn: isEqual, - }, (reader) => { - return this.sessionManagementService.activeSession.map(activeSession => activeSession?.resource).read(reader); - }).recomputeInitiallyAndOnChange(this._store); - - this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { - this.pendingTurnStateByResource.set(chatSessionResource, { - hadChangesBeforeSend: this.hasSessionChanges(chatSessionResource), - submittedAt: Date.now(), - }); - })); - - // When a turn is completed, check if there were changes before the turn and if there are changes after the turn. - // If there were no changes before the turn and there are changes after the turn, show the auxiliary bar. - this._register(autorun((reader) => { - const activeSessionResource = activeSessionResourceObs.read(reader); - if (!activeSessionResource) { - return; - } - - const pendingTurnState = this.pendingTurnStateByResource.get(activeSessionResource); - if (!pendingTurnState) { - return; - } - - const activeSession = this.agentSessionsService.getSession(activeSessionResource); - const turnCompleted = !!activeSession?.timing.lastRequestEnded && activeSession.timing.lastRequestEnded >= pendingTurnState.submittedAt; - if (!turnCompleted) { - return; - } - - const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource, reader); - if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { - this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); - } - - this.pendingTurnStateByResource.delete(activeSessionResource); - })); - - // When the session is switched, show the auxiliary bar if there are pending changes from the session - this._register(autorun(reader => { - const sessionResource = activeSessionResourceObs.read(reader); - if (!sessionResource) { - this.syncAuxiliaryBarVisibility(false); - return; - } - - const hasChanges = this.hasSessionChanges(sessionResource, reader); - this.syncAuxiliaryBarVisibility(hasChanges); - })); - } - - private hasSessionChanges(sessionResource: URI, reader?: IReader): boolean { - const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; - - let editingSessionCount = 0; - if (!isBackgroundSession) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); - editingSessionCount = editingSession ? editingSession.entries.read(reader).length : 0; - } - - const session = this.agentSessionsService.getSession(sessionResource); - const sessionFilesCount = session?.changes instanceof Array ? session.changes.length : 0; - - return editingSessionCount + sessionFilesCount > 0; - } - - private syncAuxiliaryBarVisibility(hasChanges: boolean): void { - const shouldHideAuxiliaryBar = !hasChanges; - const isAuxiliaryBarVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); - if (shouldHideAuxiliaryBar === !isAuxiliaryBarVisible) { - return; - } - - this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); - } -} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsListControl.ts b/src/vs/sessions/contrib/sessions/browser/sessionsListControl.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsListControl.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 059c8e51cdd7f..d1bc410b55ce6 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -3,69 +3,109 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { localize } from '../../../../nls.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; -import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; -import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { INewSession, LocalNewSession, RemoteNewSession } from '../../chat/browser/newSession.js'; +import { ISessionsProvidersService } from './sessionsProvidersService.js'; +import { ISessionType, ISendRequestOptions, ISessionsChangeEvent } from './sessionsProvider.js'; +import { ISessionData, ISessionWorkspace, GITHUB_REMOTE_FILE_SCHEME } from '../common/sessionData.js'; +import { IGitHubSessionContext } from '../../github/common/types.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +/** + * Configuration properties available on new/pending sessions. + * Not part of the public {@link ISessionData} contract but present on + * concrete session implementations (CopilotCLISession, RemoteNewSession, AgentHostNewSession). + */ + export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); +/** + * The provider ID of the active session (e.g., 'default-copilot', 'agenthost-hostA'). + */ +export const ActiveSessionProviderIdContext = new RawContextKey('activeSessionProviderId', '', localize('activeSessionProviderId', "The provider ID of the active session")); + +/** + * The session type of the active session (e.g., 'copilotcli', 'cloud'). + */ +export const ActiveSessionTypeContext = new RawContextKey('activeSessionType', '', localize('activeSessionType', "The session type of the active session")); + +export const IsActiveSessionBackgroundProviderContext = new RawContextKey('isActiveSessionBackgroundProvider', false, localize('isActiveSessionBackgroundProvider', "Whether the active session uses the background agent provider")); + //#region Active Session Service const LAST_SELECTED_SESSION_KEY = 'agentSessions.lastSelectedSession'; -const repositoryOptionId = 'repository'; +const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId'; /** * An active session item extends IChatSessionItem with repository information. * - For agent session items: repository is the workingDirectory from metadata * - For new sessions: repository comes from the session option with id 'repository' */ -export type IActiveSessionItem = (INewSession | IAgentSession) & { - readonly label?: string; +export interface ISessionsManagementService { + readonly _serviceBrand: undefined; + + // -- Sessions -- + /** - * The repository URI for this session. + * Get all sessions from all registered providers. */ - readonly repository: URI | undefined; + getSessions(): ISessionData[]; /** - * The worktree URI for this session. + * Get a session by its resource URI. */ - readonly worktree: URI | undefined; -}; + getSession(resource: URI): ISessionData | undefined; -export interface ISessionsManagementService { - readonly _serviceBrand: undefined; + /** + * Get all session types from all registered providers. + */ + getAllSessionTypes(): ISessionType[]; + + /** + * Fires when available session types change (providers added/removed). + */ + readonly onDidChangeSessionTypes: Event; + + /** + * Fires when sessions change across any provider. + */ + readonly onDidChangeSessions: Event; + + // -- Active Session -- /** - * Observable for the currently active session. + * Observable for the currently active session as {@link ISessionData}. */ - readonly activeSession: IObservable; + readonly activeSession: IObservable; /** - * Returns the currently active session, if any. + * Observable for the currently active sessions provider ID. + * When only one provider exists, it is selected automatically. */ - getActiveSession(): IActiveSessionItem | undefined; + readonly activeProviderId: IObservable; + + /** + * Set the active sessions provider by ID. + */ + setActiveProvider(providerId: string): void; /** * Select an existing session as the active session. * Sets `isNewChatSession` context to false and opens the session. */ - openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise; + openSession(sessionResource: URI): Promise; /** * Switch to the new-session view. @@ -74,22 +114,63 @@ export interface ISessionsManagementService { openNewSessionView(): void; /** - * Create a pending session object for the given target type. - * Local sessions collect options locally; remote sessions notify the extension. + * Returns the repository URI for the given session, if available. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + getSessionRepositoryUri(session: IAgentSession): URI | undefined; /** - * Open a new session, apply options, and send the initial request. - * Looks up the session by resource URI and builds send options from it. + * Create a new session for the given workspace. + * Delegates to the provider identified by providerId. */ - sendRequestForNewSession(sessionResource: URI): Promise; + createNewSession(providerId: string, workspace: ISessionWorkspace): ISessionData; + + /** + * Send the initial request for a session. + */ + sendRequest(session: ISessionData, options: ISendRequestOptions): Promise; + + /** + * Update the session type for a new session. + * The provider may recreate the session object. + * If the session is the active session, the active session data is updated. + */ + setSessionType(session: ISessionData, type: ISessionType): Promise; /** * Commit files in a worktree and refresh the agent sessions model * so the Changes view reflects the update. */ - commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise; + commitWorktreeFiles(session: ISessionData, fileUris: URI[]): Promise; + + /** + * Derive a GitHub context (owner, repo, prNumber) from an active session. + * Returns `undefined` if the session is not associated with a GitHub repository. + */ + getGitHubContext(session: ISessionData): IGitHubSessionContext | undefined; + + /** + * Derive a GitHub context from a session resource URI. + * Looks up the agent session internally and resolves repository info. + */ + getGitHubContextForSession(sessionResource: URI): IGitHubSessionContext | undefined; + + /** + * Resolve a relative file path to a full URI based on the session's repository/worktree. + */ + resolveSessionFileUri(sessionResource: URI, relativePath: string): URI | undefined; + + // -- Session Actions -- + + /** Archive a session. */ + archiveSession(session: ISessionData): Promise; + /** Unarchive a session. */ + unarchiveSession(session: ISessionData): Promise; + /** Delete a session. */ + deleteSession(session: ISessionData): Promise; + /** Rename a session. */ + renameSession(session: ISessionData, title: string): Promise; + /** Mark a session as read or unread. */ + setRead(session: ISessionData, read: boolean): void; } export const ISessionsManagementService = createDecorator('sessionsManagementService'); @@ -98,31 +179,44 @@ export class SessionsManagementService extends Disposable implements ISessionsMa declare readonly _serviceBrand: undefined; - private readonly _activeSession = observableValue(this, undefined); - readonly activeSession: IObservable = this._activeSession; - private readonly _activeSessionDisposables = this._register(new DisposableStore()); + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; - private readonly _newSession = this._register(new MutableDisposable()); + private readonly _onDidChangeSessionTypes = this._register(new Emitter()); + readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; + + private _sessionTypes: readonly ISessionType[] = []; + + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; + private readonly _newSessionObservable = observableValue(this, undefined); + readonly newSession: IObservable = this._newSessionObservable; + private readonly _activeProviderId = observableValue(this, undefined); + readonly activeProviderId: IObservable = this._activeProviderId; private lastSelectedSession: URI | undefined; private readonly isNewChatSessionContext: IContextKey; + private readonly _activeSessionProviderId: IContextKey; + private readonly _activeSessionType: IContextKey; + private readonly _isBackgroundProvider: IContextKey; constructor( @IStorageService private readonly storageService: IStorageService, - @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IChatService private readonly chatService: IChatService, - @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, @ICommandService private readonly commandService: ICommandService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); // Bind context key to active session state. // isNewSession is false when there are any established sessions in the model. this.isNewChatSessionContext = IsNewChatSessionContext.bindTo(contextKeyService); + this._activeSessionProviderId = ActiveSessionProviderIdContext.bindTo(contextKeyService); + this._activeSessionType = ActiveSessionTypeContext.bindTo(contextKeyService); + this._isBackgroundProvider = IsActiveSessionBackgroundProviderContext.bindTo(contextKeyService); // Load last selected session this.lastSelectedSession = this.loadLastSelectedSession(); @@ -130,56 +224,92 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // Save on shutdown this._register(this.storageService.onWillSaveState(() => this.saveLastSelectedSession())); - // Update active session when the agent sessions model changes (e.g., metadata updates with worktree/repository info) - this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.refreshActiveSessionFromModel())); + // Forward session change events from providers and update active session + this._register(this.sessionsProvidersService.onDidChangeSessions(e => this.onDidChangeSessionsFromSessionsProviders(e))); - // Clear active session if the active session gets archived - this._register(this.agentSessionsService.model.onDidChangeSessionArchivedState(e => { - if (e.isArchived()) { - const currentActive = this._activeSession.get(); - if (currentActive && currentActive.resource.toString() === e.resource.toString()) { - this.openNewSessionView(); - } - } + // Restore or auto-select active provider + this._initActiveProvider(); + this._register(this.sessionsProvidersService.onDidChangeProviders(() => { + this._initActiveProvider(); + this._updateSessionTypes(); })); } - private refreshActiveSessionFromModel(): void { - const currentActive = this._activeSession.get(); - if (!currentActive) { + private _initActiveProvider(): void { + const providers = this.sessionsProvidersService.getProviders(); + if (providers.length === 0) { return; } - const agentSession = this.agentSessionsService.model.getSession(currentActive.resource); - if (!agentSession) { - // Only switch sessions if the active session was a known agent session - // that got deleted. New session resources that aren't yet in the model - // should not trigger a switch. - if (isAgentSession(currentActive)) { - this.showNextSession(); - } + // If already set and still valid, keep it + const current = this._activeProviderId.get(); + if (current && providers.some(p => p.id === current)) { + return; + } + + // Try to restore from storage + const stored = this.storageService.get(ACTIVE_PROVIDER_KEY, StorageScope.PROFILE); + if (stored && providers.some(p => p.id === stored)) { + this._activeProviderId.set(stored, undefined); return; } - this.setActiveSession(agentSession); + // Auto-select the first (or only) provider + this._activeProviderId.set(providers[0].id, undefined); } - private showNextSession(): void { - const sessions = this.agentSessionsService.model.sessions - .filter(s => !s.isArchived()) - .sort((a, b) => (b.timing.lastRequestEnded ?? b.timing.created) - (a.timing.lastRequestEnded ?? a.timing.created)); + setActiveProvider(providerId: string): void { + this._activeProviderId.set(providerId, undefined); + this.storageService.store(ACTIVE_PROVIDER_KEY, providerId, StorageScope.PROFILE, StorageTarget.MACHINE); + } - if (sessions.length > 0) { - this.setActiveSession(sessions[0]); - this.instantiationService.invokeFunction(openSessionDefault, sessions[0]); - } else { - this.openNewSessionView(); + private onDidChangeSessionsFromSessionsProviders(e: ISessionsChangeEvent): void { + this._onDidChangeSessions.fire(e); + const currentActive = this._activeSession.get(); + if (!currentActive) { + return; + } + + if (e.removed.length) { + if (e.removed.some(r => r.sessionId === currentActive.sessionId)) { + this.openNewSessionView(); + return; + } + } + + if (e.changed.length) { + const updated = e.changed.find(s => s.sessionId === currentActive.sessionId); + if (updated?.isArchived.get()) { + this.openNewSessionView(); + return; + } + if (updated) { + this._activeSession.set(updated, undefined); + return; + } } } - private getRepositoryFromMetadata(metadata: { readonly [key: string]: unknown } | undefined): [URI | undefined, URI | undefined] { + private getRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, boolean | undefined] { + const metadata = session.metadata; if (!metadata) { - return [undefined, undefined]; + return [undefined, undefined, undefined, undefined]; + } + + if (session.providerType === AgentSessionProviders.Cloud) { + //TODO: @osortega pass branch in metadata from extension + const branch = typeof metadata.branch === 'string' ? metadata.branch : 'HEAD'; + const repositoryUri = URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}` + }); + return [repositoryUri, undefined, undefined, undefined]; + } + + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; + if (workingDirectoryPath) { + return [URI.file(workingDirectoryPath), undefined, undefined, undefined]; } const repositoryPath = metadata?.repositoryPath as string | undefined; @@ -188,182 +318,116 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const worktreePath = metadata?.worktreePath as string | undefined; const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + const worktreeBranchName = metadata?.branchName as string | undefined; + const worktreeBaseBranchProtected = metadata?.baseBranchProtected as boolean | undefined; + return [ URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, - URI.isUri(worktreePathUri) ? worktreePathUri : undefined]; + URI.isUri(worktreePathUri) ? worktreePathUri : undefined, + worktreeBranchName, + worktreeBaseBranchProtected]; } - private getRepositoryFromSessionOption(sessionResource: URI): URI | undefined { - const optionValue = this.chatSessionsService.getSessionOption(sessionResource, repositoryOptionId); - if (!optionValue) { - return undefined; - } + getSessions(): ISessionData[] { + return this.sessionsProvidersService.getSessions(); + } - // Option value can be a string or IChatSessionProviderOptionItem - const optionId = typeof optionValue === 'string' ? optionValue : (optionValue as IChatSessionProviderOptionItem).id; - if (!optionId) { - return undefined; - } + getSession(resource: URI): ISessionData | undefined { + return this.sessionsProvidersService.getSessions().find(s => this.uriIdentityService.extUri.isEqual(s.resource, resource)); + } - try { - return URI.parse(optionId); - } catch { - return undefined; + getAllSessionTypes(): ISessionType[] { + return [...this._sessionTypes]; + } + + private _collectSessionTypes(): ISessionType[] { + const types: ISessionType[] = []; + const seen = new Set(); + for (const provider of this.sessionsProvidersService.getProviders()) { + for (const type of provider.sessionTypes) { + if (!seen.has(type.id)) { + seen.add(type.id); + types.push(type); + } + } } + return types; } - getActiveSession(): IActiveSessionItem | undefined { - return this._activeSession.get(); + private _updateSessionTypes(): void { + const newTypes = this._collectSessionTypes(); + const oldIds = new Set(this._sessionTypes.map(t => t.id)); + const newIds = new Set(newTypes.map(t => t.id)); + if (oldIds.size !== newIds.size || [...oldIds].some(id => !newIds.has(id))) { + this._sessionTypes = newTypes; + this._onDidChangeSessionTypes.fire(); + } } - async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { - this.isNewChatSessionContext.set(false); - const existingSession = this.agentSessionsService.model.getSession(sessionResource); - if (existingSession) { - await this.openExistingSession(existingSession, openOptions); - } else if (this._newSession.value && this.uriIdentityService.extUri.isEqual(sessionResource, this._newSession.value.resource)) { - await this.openNewSession(this._newSession.value); + async openSession(sessionResource: URI): Promise { + const sessionData = this.getSession(sessionResource); + if (!sessionData) { + this.logService.warn(`[SessionsManagement] openSession: session not found: ${sessionResource.toString()}`); + throw new Error(`Session with resource ${sessionResource.toString()} not found`); } + this.logService.info(`[SessionsManagement] openSession: ${sessionResource.toString()} provider=${sessionData.providerId}`); + this.isNewChatSessionContext.set(false); + this.setActiveSession(sessionData); + + await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + createNewSession(providerId: string, workspace: ISessionWorkspace): ISessionData { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } - let newSession: INewSession; - if (target === AgentSessionProviders.Background || target === AgentSessionProviders.Local) { - newSession = new LocalNewSession(sessionResource, defaultRepoUri, this.chatSessionsService, this.logService); - } else { - newSession = new RemoteNewSession(sessionResource, target, this.chatSessionsService, this.logService); + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === providerId); + if (!provider) { + throw new Error(`Sessions provider '${providerId}' not found`); } - this._newSession.value = newSession; - this.setActiveSession(newSession); - return newSession; - } - /** - * Open an existing agent session - set it as active and reveal it. - */ - private async openExistingSession(session: IAgentSession, openOptions?: ISessionOpenOptions): Promise { - this.setActiveSession(session); - await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); - } + const sessionData = provider.createNewSession(workspace); - /** - * Open a new remote session - load the model first, then show it in the ChatViewPane. - */ - private async openNewSession(newSession: INewSession): Promise { - this.setActiveSession(newSession); - const sessionResource = newSession.resource; - const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); - if (!chatWidget?.viewModel) { - this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); - return; - } - const repository = this.getRepositoryFromSessionOption(sessionResource); - this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._newSessionObservable.set(sessionData, undefined); + this.setActiveSession(sessionData); + this._activeSession.set(sessionData, undefined); + return sessionData; } - async sendRequestForNewSession(sessionResource: URI): Promise { - const session = this._newSession.value; - if (!session) { - this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); - return; + async setSessionType(session: ISessionData, type: ISessionType): Promise { + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId); + if (!provider) { + throw new Error(`Sessions provider '${session.providerId}' not found`); } - if (!this.uriIdentityService.extUri.isEqual(sessionResource, session.resource)) { - this.logService.error(`[SessionsManagementService] Session resource mismatch. Expected: ${session.resource.toString()}, received: ${sessionResource.toString()}`); - return; - } + const updatedSession = provider.setSessionType(session.sessionId, type); - const query = session.query; - if (!query) { - this.logService.error('[SessionsManagementService] No query set on session'); - return; + const activeSession = this._activeSession.get(); + if (activeSession && activeSession.sessionId === session.sessionId) { + this._newSessionObservable.set(updatedSession, undefined); + this._activeSession.set(updatedSession, undefined); } - - const contribution = this.chatSessionsService.getChatSessionContribution(session.target); - const sendOptions: IChatSendRequestOptions = { - location: ChatAgentLocation.Chat, - userSelectedModelId: session.modelId, - modeInfo: { - kind: ChatModeKind.Agent, - isBuiltin: true, - modeInstructions: undefined, - modeId: 'agent', - applyCodeBlockSuggestionId: undefined, - }, - agentIdSilent: contribution?.type, - attachedContext: session.attachedContext, - }; - - await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); - await this.doSendRequestForNewSession(session, query, sendOptions, session.selectedOptions); - - // Clean up the session after sending (setter disposes the previous value) - this._newSession.value = undefined; } - private async doSendRequestForNewSession(session: INewSession, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { - // 1. Open the session - loads the model and shows the ChatViewPane - await this.openSession(session.resource); - - // 2. Apply selected options (repository, branch, etc.) to the contributed session - if (selectedOptions && selectedOptions.size > 0) { - const modelRef = this.chatService.acquireExistingSession(session.resource); - if (modelRef) { - const model = modelRef.object; - const contributedSession = model.contributedChatSession; - if (contributedSession) { - const initialSessionOptions = [...selectedOptions.entries()].map( - ([optionId, value]) => ({ optionId, value }) - ); - model.setContributedChatSession({ - ...contributedSession, - initialSessionOptions, - }); - } - modelRef.dispose(); - } - } + async sendRequest(session: ISessionData, options: ISendRequestOptions): Promise { + this.isNewChatSessionContext.set(false); - // 3. Send the request - const existingResources = new Set( - this.agentSessionsService.model.sessions.map(s => s.resource.toString()) - ); - const result = await this.chatService.sendRequest(session.resource, query, sendOptions); - if (result.kind === 'rejected') { - this.logService.error(`[ActiveSessionService] sendRequest rejected: ${result.reason}`); - return; + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId); + if (!provider) { + throw new Error(`Sessions provider '${session.providerId}' not found`); } - // 4. Wait for the extension to create an agent session, then set it as active - let newSession = this.agentSessionsService.model.sessions.find( - s => !existingResources.has(s.resource.toString()) - ); - - if (!newSession) { - let listener: IDisposable | undefined; - newSession = await Promise.race([ - new Promise(resolve => { - listener = this.agentSessionsService.model.onDidChangeSessions(() => { - const session = this.agentSessionsService.model.sessions.find( - s => !existingResources.has(s.resource.toString()) - ); - if (session) { - resolve(session); - } - }); - }), - new Promise(resolve => setTimeout(() => resolve(undefined), 30_000)), - ]); - listener?.dispose(); - } - - if (newSession) { - this.setActiveSession(newSession); + // Delegate to the provider + const result = await provider.sendRequest(session.sessionId, options); + + // Set the new agent session as active + if (result) { + this._activeSession.set(result, undefined); } + + // Clean up + this._newSessionObservable.set(undefined, undefined); } openNewSessionView(): void { @@ -371,47 +435,36 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (this.isNewChatSessionContext.get()) { return; } + this.setActiveSession(undefined); this.isNewChatSessionContext.set(true); - this._activeSession.set(undefined, undefined); } - private setActiveSession(session: IAgentSession | INewSession | undefined): void { - this._activeSessionDisposables.clear(); - let activeSessionItem: IActiveSessionItem | undefined; + getSessionRepositoryUri(session: IAgentSession): URI | undefined { + const [repositoryUri] = this.getRepositoryFromMetadata(session); + return repositoryUri; + } + + private setActiveSession(session: ISessionData | undefined): void { + // Update context keys from session data + this._activeSessionProviderId.set(session?.providerId ?? ''); + this._activeSessionType.set(session?.sessionType ?? ''); + this._isBackgroundProvider.set(session?.sessionType === AgentSessionProviders.Background); + + if (session && isAgentSession(session)) { + this.lastSelectedSession = session.resource; + } + if (session) { - if (isAgentSession(session)) { - this.lastSelectedSession = session.resource; - const [repository, worktree] = this.getRepositoryFromMetadata(session.metadata); - activeSessionItem = { - ...session, - repository, - worktree, - }; - } else { - activeSessionItem = { - ...session, - repository: session.repoUri, - worktree: undefined, - }; - this._activeSessionDisposables.add(session.onDidChange(e => { - if (e === 'repoUri') { - this._activeSession.set({ - ...session, - repository: session.repoUri, - worktree: undefined, - }, undefined); - } - })); - } - this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}, repository: ${activeSessionItem.repository?.toString() ?? 'none'}`); + this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`); } else { - this.logService.info('[ActiveSessionService] Active session cleared'); + this.logService.trace('[ActiveSessionService] Active session cleared'); } - this._activeSession.set(activeSessionItem, undefined); + + this._activeSession.set(session, undefined); } - async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise { - const worktreeUri = session.worktree; + async commitWorktreeFiles(session: ISessionData, fileUris: URI[]): Promise { + const worktreeUri = session.workspace.get()?.repositories[0]?.workingDirectory; if (!worktreeUri) { throw new Error('Cannot commit worktree files: active session has no associated worktree'); } @@ -424,6 +477,97 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); } + getGitHubContext(session: ISessionData): IGitHubSessionContext | undefined { + // 1. Try parsing a github-remote-file URI (Cloud sessions) + const repoUri = session.workspace.get()?.repositories[0]?.uri; + if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = repoUri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + const owner = decodeURIComponent(parts[0]); + const repo = decodeURIComponent(parts[1]); + const prNumber = this._parsePRNumberFromSession(session); + return { owner, repo, prNumber }; + } + } + + // 2. Try from agent session metadata (Background sessions) + const agentSession = this.agentSessionsService.model.getSession(session.resource); + if (agentSession?.metadata) { + const metadata = agentSession.metadata; + + // owner + name fields + if (typeof metadata.owner === 'string' && typeof metadata.name === 'string') { + const prNumber = this._parsePRNumberFromSession(session); + return { owner: metadata.owner, repo: metadata.name, prNumber }; + } + + // repositoryNwo: "owner/repo" + if (typeof metadata.repositoryNwo === 'string') { + const parts = (metadata.repositoryNwo as string).split('/'); + if (parts.length === 2) { + const prNumber = this._parsePRNumberFromSession(session); + return { owner: parts[0], repo: parts[1], prNumber }; + } + } + + // pullRequestUrl: "https://github.com/{owner}/{repo}/pull/{number}" + if (typeof metadata.pullRequestUrl === 'string') { + const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(metadata.pullRequestUrl as string); + if (match) { + return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) }; + } + } + } + + return undefined; + } + + getGitHubContextForSession(sessionResource: URI): IGitHubSessionContext | undefined { + // Try finding the ISessionData first (preferred path) + const sessionData = this.sessionsProvidersService.getSessions().find(s => s.resource.toString() === sessionResource.toString()); + if (sessionData) { + return this.getGitHubContext(sessionData); + } + + // Fallback: construct context directly from agent session metadata + const agentSession = this.agentSessionsService.model.getSession(sessionResource); + if (!agentSession) { + return undefined; + } + const [repository] = this.getRepositoryFromMetadata(agentSession); + if (repository && repository.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = repository.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: decodeURIComponent(parts[0]), repo: decodeURIComponent(parts[1]), prNumber: undefined }; + } + } + return undefined; + } + + resolveSessionFileUri(sessionResource: URI, relativePath: string): URI | undefined { + const agentSession = this.agentSessionsService.model.getSession(sessionResource); + if (!agentSession) { + return undefined; + } + const [repository, worktree] = this.getRepositoryFromMetadata(agentSession); + const baseUri = worktree ?? repository; + if (!baseUri) { + return undefined; + } + return URI.joinPath(baseUri, relativePath); + } + + private _parsePRNumberFromSession(session: ISessionData): number | undefined { + const prUri = session.pullRequestUri.get(); + if (prUri) { + const match = /\/pull\/(\d+)/.exec(prUri.path); + if (match) { + return parseInt(match[1], 10); + } + } + return undefined; + } + private loadLastSelectedSession(): URI | undefined { const cached = this.storageService.get(LAST_SELECTED_SESSION_KEY, StorageScope.WORKSPACE); if (!cached) { @@ -442,6 +586,30 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.storageService.store(LAST_SELECTED_SESSION_KEY, this.lastSelectedSession.toString(), StorageScope.WORKSPACE, StorageTarget.MACHINE); } } + + // -- Session Actions -- + + async archiveSession(session: ISessionData): Promise { + await this.sessionsProvidersService.archiveSession(session.sessionId); + } + + async unarchiveSession(session: ISessionData): Promise { + await this.sessionsProvidersService.unarchiveSession(session.sessionId); + } + + async deleteSession(session: ISessionData): Promise { + // Clear the chat widget before removing from storage + await this.chatWidgetService.getWidgetBySessionResource(session.resource)?.clear(); + await this.sessionsProvidersService.deleteSession(session.sessionId); + } + + async renameSession(session: ISessionData, title: string): Promise { + await this.sessionsProvidersService.renameSession(session.sessionId, title); + } + + setRead(session: ISessionData, read: boolean): void { + this.sessionsProvidersService.setRead(session.sessionId, read); + } } //#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts b/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts new file mode 100644 index 0000000000000..7a06db1a2cdab --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvider.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ISessionData, ISessionWorkspace } from '../common/sessionData.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; + +/** + * A platform-level session type identifying an agent backend. + * Lightweight label — says nothing about where it runs or how it's configured. + */ +export interface ISessionType { + /** Unique identifier (e.g., 'copilot-cli', 'copilot-cloud', 'claude-code'). */ + readonly id: string; + /** Display label (e.g., 'Copilot CLI', 'Cloud'). */ + readonly label: string; + /** Icon for this session type. */ + readonly icon: ThemeIcon; + /** Whether this session type requires workspace trust before creating a session. */ + readonly requiresWorkspaceTrust?: boolean; +} + +/** + * A browse action contributed by a sessions provider. + * Shown in the workspace picker (e.g., "Browse Folders...", "Browse Repositories..."). + */ +export interface ISessionsBrowseAction { + /** Display label for the browse action. */ + readonly label: string; + /** Icon for the browse action. */ + readonly icon: ThemeIcon; + /** The provider that owns this action. */ + readonly providerId: string; + /** Execute the browse action and return the selected workspace, or undefined if cancelled. */ + execute(): Promise; +} + +/** + * Event fired when sessions change within a provider. + */ +export interface ISessionsChangeEvent { + readonly added: readonly ISessionData[]; + readonly removed: readonly ISessionData[]; + readonly changed: readonly ISessionData[]; +} + +/** + * Options for sending a request to a session. + */ +export interface ISendRequestOptions { + /** The query text to send. */ + readonly query: string; + /** Optional attached context entries. */ + readonly attachedContext?: IChatRequestVariableEntry[]; +} + +/** + * A sessions provider encapsulates a compute environment. + * It owns workspace discovery, session creation, session listing, and picker contributions. + * + * One provider can serve multiple session types. Multiple provider instances can + * serve the same session type (e.g., one per remote agent host). + */ +export interface ISessionsProvider { + /** Unique provider instance ID (e.g., 'default-copilot', 'agenthost-hostA'). */ + readonly id: string; + /** Display label for this provider. */ + readonly label: string; + /** Icon for this provider. */ + readonly icon: ThemeIcon; + /** Session types this provider supports. */ + readonly sessionTypes: readonly ISessionType[]; + + // -- Workspaces -- + + /** Browse actions shown in the workspace picker. */ + readonly browseActions: readonly ISessionsBrowseAction[]; + /** Resolve a repository URI to a session workspace with label and icon. */ + resolveWorkspace(repositoryUri: URI): ISessionWorkspace; + + // -- Sessions (existing) -- + + /** Returns all sessions owned by this provider. */ + getSessions(): ISessionData[]; + /** Fires when sessions are added, removed, or changed. */ + readonly onDidChangeSessions: Event; + + // -- Session Management -- + + /** Create a new session for the given workspace. */ + createNewSession(workspace: ISessionWorkspace): ISessionData; + /** Update the session type for a session. */ + setSessionType(sessionId: string, type: ISessionType): ISessionData; + /** Returns session types available for the given session. */ + getSessionTypes(session: ISessionData): ISessionType[]; + /** Rename a session. */ + renameSession(sessionId: string, title: string): Promise; + /** Set the model for a session. */ + setModel(sessionId: string, modelId: string): void; + /** Archive a session. */ + archiveSession(sessionId: string): Promise; + /** Unarchive a session. */ + unarchiveSession(sessionId: string): Promise; + /** Delete a session. */ + deleteSession(sessionId: string): Promise; + /** Mark a session as read or unread. */ + setRead(sessionId: string, read: boolean): void; + + // -- Send -- + + /** Send the initial request for a new session. Returns the created session data. */ + sendRequest(sessionId: string, options: ISendRequestOptions): Promise; +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts new file mode 100644 index 0000000000000..b76aa6b4f7927 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ISessionData, ISessionWorkspace } from '../common/sessionData.js'; +import { ISessionsChangeEvent, ISessionsProvider, ISessionType } from './sessionsProvider.js'; +import { URI } from '../../../../base/common/uri.js'; + +export const ISessionsProvidersService = createDecorator('sessionsProvidersService'); + +/** + * Central service that aggregates sessions across all registered providers. + * Owns the provider registry, unified session list, active session tracking, + * and routes session actions to the correct provider. + */ +export interface ISessionsProvidersService { + readonly _serviceBrand: undefined; + + // -- Provider Registry -- + + /** Register a sessions provider. Returns a disposable to unregister. */ + registerProvider(provider: ISessionsProvider): IDisposable; + /** Get all registered providers. */ + getProviders(): ISessionsProvider[]; + /** Fires when providers are added or removed. */ + readonly onDidChangeProviders: Event; + + // -- Session Types -- + + /** Get available session types for a provider. */ + getSessionTypesForProvider(providerId: string): ISessionType[]; + /** Get available session types for a session from its provider. */ + getSessionTypes(session: ISessionData): ISessionType[]; + + // -- Aggregated Sessions -- + + /** Get all sessions from all providers. */ + getSessions(): ISessionData[]; + /** Get a session by its globally unique ID. */ + getSession(sessionId: string): ISessionData | undefined; + /** Fires when sessions change across any provider. */ + readonly onDidChangeSessions: Event; + + // -- Session Actions (routed to the correct provider via sessionId) -- + + /** Archive a session. */ + archiveSession(sessionId: string): Promise; + /** Unarchive a session. */ + unarchiveSession(sessionId: string): Promise; + /** Delete a session. */ + deleteSession(sessionId: string): Promise; + /** Rename a session. */ + renameSession(sessionId: string, title: string): Promise; + /** Mark a session as read or unread. */ + setRead(sessionId: string, read: boolean): void; + /** Resolve a repository URI to a session workspace using the given provider. */ + resolveWorkspace(providerId: string, repositoryUri: URI): ISessionWorkspace | undefined; +} + +/** + * Separator used to construct globally unique session IDs: `${providerId}:${localId}`. + */ +const SESSION_ID_SEPARATOR = ':'; + +export class SessionsProvidersService extends Disposable implements ISessionsProvidersService { + declare readonly _serviceBrand: undefined; + + private readonly _providers = new Map(); + + private readonly _onDidChangeProviders = this._register(new Emitter()); + readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + // -- Provider Registry -- + + registerProvider(provider: ISessionsProvider): IDisposable { + if (this._providers.has(provider.id)) { + throw new Error(`Sessions provider '${provider.id}' is already registered.`); + } + + const disposables = new DisposableStore(); + + // Forward session change events from this provider + disposables.add(provider.onDidChangeSessions(e => { + this._onDidChangeSessions.fire(e); + })); + + this._providers.set(provider.id, { provider, disposables }); + this._onDidChangeProviders.fire(); + + return toDisposable(() => { + const entry = this._providers.get(provider.id); + if (entry) { + entry.disposables.dispose(); + this._providers.delete(provider.id); + this._onDidChangeProviders.fire(); + } + }); + } + + getProviders(): ISessionsProvider[] { + return Array.from(this._providers.values(), e => e.provider); + } + + // -- Session Types -- + + getSessionTypesForProvider(providerId: string): ISessionType[] { + const entry = this._providers.get(providerId); + if (!entry) { + return []; + } + return [...entry.provider.sessionTypes]; + } + + getSessionTypes(session: ISessionData): ISessionType[] { + const entry = this._providers.get(session.providerId); + if (!entry) { + return []; + } + return entry.provider.getSessionTypes(session); + } + + // -- Aggregated Sessions -- + + getSessions(): ISessionData[] { + const sessions: ISessionData[] = []; + for (const { provider } of this._providers.values()) { + sessions.push(...provider.getSessions()); + } + return sessions; + } + + getSession(sessionId: string): ISessionData | undefined { + const { provider } = this._resolveProvider(sessionId); + if (!provider) { + return undefined; + } + return provider.getSessions().find(s => s.sessionId === sessionId); + } + + // -- Session Actions -- + + async archiveSession(sessionId: string): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.archiveSession(sessionId); + } + } + + async unarchiveSession(sessionId: string): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.unarchiveSession(sessionId); + } + } + + async deleteSession(sessionId: string): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.deleteSession(sessionId); + } + } + + async renameSession(sessionId: string, title: string): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.renameSession(sessionId, title); + } + } + + setRead(sessionId: string, read: boolean): void { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + provider.setRead(sessionId, read); + } + } + + resolveWorkspace(providerId: string, repositoryUri: URI): ISessionWorkspace | undefined { + const entry = this._providers.get(providerId); + return entry?.provider.resolveWorkspace(repositoryUri); + } + + // -- Private Helpers -- + + /** + * Extract provider ID from a globally unique session ID and look up the provider. + */ + private _resolveProvider(sessionId: string): { provider: ISessionsProvider | undefined; localId: string } { + const separatorIndex = sessionId.indexOf(SESSION_ID_SEPARATOR); + if (separatorIndex === -1) { + return { provider: undefined, localId: sessionId }; + } + const providerId = sessionId.substring(0, separatorIndex); + const localId = sessionId.substring(separatorIndex + 1); + const entry = this._providers.get(providerId); + return { provider: entry?.provider, localId }; + } +} + +registerSingleton(ISessionsProvidersService, SessionsProvidersService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsProvidersServiceInterface.ts b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersServiceInterface.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersServiceInterface.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index e21c25d56e9e7..78288c278979f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,15 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, EventType, getActiveWindow, reset } from '../../../../base/browser/dom.js'; +import { Separator } from '../../../../base/common/actions.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { localize } from '../../../../nls.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IMarshalledAgentSessionContext } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -24,13 +33,11 @@ import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/ import { autorun } from '../../../../base/common/observable.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { getAgentChangesSummary, hasValidDiff, IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { basename } from '../../../../base/common/resources.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { ViewAllSessionChangesAction } from '../../../../workbench/contrib/chat/browser/chatEditing/chatEditingActions.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ISessionsProvidersService } from './sessionsProvidersService.js'; +import { SessionStatus } from '../common/sessionData.js'; +import { Codicon } from '../../../../base/common/codicons.js'; /** * Sessions Title Bar Widget - renders the active chat session title @@ -40,7 +47,9 @@ import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextke * - Kind icon at the beginning (provider type icon) * - Session title * - Repository folder name - * - Changes summary (+insertions -deletions) + * + * Session actions (changes, terminal, etc.) are rendered via the + * SessionTitleActions menu toolbar next to the session title. * * On click, opens the sessions picker. */ @@ -64,20 +73,37 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IChatService private readonly chatService: IChatService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ICommandService private readonly commandService: ICommandService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(undefined, action, options); - // Re-render when the active session changes + + // Re-render when the active session, its data, or the active provider changes this._register(autorun(reader => { - const activeSession = this.activeSessionService.activeSession.read(reader); - this._trackModelTitleChanges(activeSession?.resource); + const sessionData = this.activeSessionService.activeSession.read(reader); + this._trackModelTitleChanges(sessionData?.resource); + if (sessionData) { + sessionData.title.read(reader); + sessionData.status.read(reader); + } + this.activeSessionService.activeProviderId.read(reader); this._lastRenderState = undefined; this._render(); })); // Re-render when sessions data changes (e.g., changes info updated) - this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._register(this.activeSessionService.onDidChangeSessions(() => { + this._lastRenderState = undefined; + this._render(); + })); + + // Re-render when providers change (affects provider picker visibility) + this._register(this.sessionsProvidersService.onDidChangeProviders(() => { this._lastRenderState = undefined; this._render(); })); @@ -117,10 +143,9 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); - const changes = this._getChanges(); - + const unreadCount = this._countUnreadSessions(); // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changes?.insertions ?? ''}|${changes?.deletions ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${unreadCount}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -137,7 +162,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); this._container.tabIndex = 0; - // Center group: icon + label + folder + changes together + // Session pill: icon + label + folder together + const sessionPill = $('span.agent-sessions-titlebar-pill'); + + // Center group: icon + label + folder const centerGroup = $('span.agent-sessions-titlebar-center'); // Kind icon at the beginning @@ -151,74 +179,74 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { labelEl.textContent = label; centerGroup.appendChild(labelEl); - // Folder and changes shown next to the title - if (repoLabel || changes) { - if (repoLabel) { - const separator1 = $('span.agent-sessions-titlebar-separator'); - separator1.textContent = '\u00B7'; - centerGroup.appendChild(separator1); - - const repoEl = $('span.agent-sessions-titlebar-repo'); - repoEl.textContent = repoLabel; - centerGroup.appendChild(repoEl); - } - - if (changes) { - const separator2 = $('span.agent-sessions-titlebar-separator'); - separator2.textContent = '\u00B7'; - centerGroup.appendChild(separator2); - - const changesEl = $('span.agent-sessions-titlebar-changes'); - - // Diff icon - const changesIconEl = $('span.agent-sessions-titlebar-changes-icon' + ThemeIcon.asCSSSelector(Codicon.diffMultiple)); - changesEl.appendChild(changesIconEl); - - const addedEl = $('span.agent-sessions-titlebar-added'); - addedEl.textContent = `+${changes.insertions}`; - changesEl.appendChild(addedEl); - - const removedEl = $('span.agent-sessions-titlebar-removed'); - removedEl.textContent = `-${changes.deletions}`; - changesEl.appendChild(removedEl); - - centerGroup.appendChild(changesEl); + // Folder shown next to the title + if (repoLabel) { + const separator1 = $('span.agent-sessions-titlebar-separator'); + separator1.textContent = '\u00B7'; + centerGroup.appendChild(separator1); - // Separate hover for changes - this._dynamicDisposables.add(this.hoverService.setupManagedHover( - getDefaultHoverDelegate('mouse'), - changesEl, - localize('agentSessions.viewChanges', "View All Changes") - )); - - // Click on changes opens multi-diff editor - this._dynamicDisposables.add(addDisposableListener(changesEl, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this._openChanges(); - })); - } + const repoEl = $('span.agent-sessions-titlebar-repo'); + repoEl.textContent = repoLabel; + centerGroup.appendChild(repoEl); } - this._container.appendChild(centerGroup); + sessionPill.appendChild(centerGroup); - // Hover - this._dynamicDisposables.add(this.hoverService.setupManagedHover( - getDefaultHoverDelegate('mouse'), - this._container, - label - )); - - // Click handler - show sessions picker - this._dynamicDisposables.add(addDisposableListener(this._container, EventType.MOUSE_DOWN, (e) => { + // Click handler on pill + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.MOUSE_DOWN, (e) => { e.preventDefault(); e.stopPropagation(); })); - this._dynamicDisposables.add(addDisposableListener(this._container, EventType.CLICK, (e) => { + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); this._showSessionsPicker(); })); + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CONTEXT_MENU, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showContextMenu(e); + })); + + this._container.appendChild(sessionPill); + + // Session count widget (to the left of the pill) — toggles sidebar + const countWidget = $('button.agent-sessions-titlebar-count') as HTMLButtonElement; + countWidget.type = 'button'; + countWidget.tabIndex = 0; + const countIcon = $(ThemeIcon.asCSSSelector(Codicon.tasklist)); + countWidget.appendChild(countIcon); + if (unreadCount > 0) { + const countLabel = $('span.agent-sessions-titlebar-count-label'); + countLabel.textContent = `${unreadCount}`; + countWidget.appendChild(countLabel); + countWidget.setAttribute('aria-label', localize('showSidebarUnread', "Show Side Bar, {0} unread session(s)", unreadCount)); + } else { + countWidget.setAttribute('aria-label', localize('showSidebar', "Show Side Bar")); + } + // Hide when sidebar is visible (only shown when sidebar is hidden) + const updateVisibility = () => { + countWidget.style.display = this.layoutService.isVisible(Parts.SIDEBAR_PART) ? 'none' : ''; + }; + updateVisibility(); + this._dynamicDisposables.add(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART) { + updateVisibility(); + } + })); + this._dynamicDisposables.add(addDisposableListener(countWidget, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + })); + this._container.insertBefore(countWidget, sessionPill); + + // Hover + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + sessionPill, + label + )); // Keyboard handler this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { @@ -259,46 +287,23 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { /** * Get the label of the active chat session. - * Prefers the live model title over the snapshot label from the active session service. - * Falls back to a generic label if no active session is found. */ private _getActiveSessionLabel(): string { - const activeSession = this.activeSessionService.getActiveSession(); - if (activeSession?.resource) { - const model = this.chatService.getSession(activeSession.resource); - if (model?.title) { - return model.title; - } + const sessionData = this.activeSessionService.activeSession.get(); + if (sessionData) { + return sessionData.title.get() || localize('agentSessions.newSession', "New Session"); } - - if (activeSession?.label) { - return activeSession.label; - } - return localize('agentSessions.newSession', "New Session"); } /** - * Get the icon for the active session's kind/provider. + * Get the icon for the active session's type. */ private _getActiveSessionIcon(): ThemeIcon | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return undefined; + const sessionData = this.activeSessionService.activeSession.get(); + if (sessionData) { + return sessionData.icon; } - - // Try to get icon from the agent session model (has provider-resolved icon) - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - if (agentSession) { - return agentSession.icon; - } - - // Fall back to provider icon from the resource - const provider = getAgentSessionProvider(activeSession.resource); - if (provider !== undefined) { - return getAgentSessionProviderIcon(provider); - } - return undefined; } @@ -306,59 +311,69 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { * Get the repository label for the active session. */ private _getRepositoryLabel(): string | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return undefined; + const sessionData = this.activeSessionService.activeSession.get(); + if (sessionData) { + const workspace = sessionData.workspace.get(); + if (workspace) { + return workspace.label; + } } + return undefined; + } - const uri = activeSession.repository; - if (!uri) { - return undefined; + private _countUnreadSessions(): number { + let unread = 0; + for (const session of this.activeSessionService.getSessions()) { + if (!session.isArchived.get() && session.status.get() === SessionStatus.Completed && !session.isRead.get()) { + unread++; + } } - - return basename(uri); + return unread; } - /** - * Get the changes summary (insertions/deletions) for the active session. - */ - private _getChanges(): { insertions: number; deletions: number } | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return undefined; + private _showContextMenu(e: MouseEvent): void { + const sessionData = this.activeSessionService.activeSession.get(); + if (!sessionData) { + return; } - let changes: IAgentSession['changes'] | undefined; - - if (isAgentSession(activeSession)) { - changes = activeSession.changes; - } else { - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - changes = agentSession?.changes; + const agentSession = this.agentSessionsService.getSession(sessionData.resource); + if (!agentSession) { + return; } - if (!changes || !hasValidDiff(changes)) { - return undefined; - } + this.chatSessionsService.activateChatSessionItemProvider(agentSession.providerType); + + const contextOverlay: Array<[string, boolean | string]> = [ + [ChatContextKeys.isArchivedAgentSession.key, agentSession.isArchived()], + [ChatContextKeys.isPinnedAgentSession.key, agentSession.isPinned()], + [ChatContextKeys.isReadAgentSession.key, agentSession.isRead()], + [ChatContextKeys.agentSessionType.key, agentSession.providerType], + ]; + + const menu = this.menuService.createMenu(MenuId.AgentSessionsContext, this.contextKeyService.createOverlay(contextOverlay)); - return getAgentChangesSummary(changes) ?? undefined; + const marshalledContext: IMarshalledAgentSessionContext = { + session: agentSession, + sessions: [agentSession], + $mid: MarshalledId.AgentSessionContext, + }; + + this.contextMenuService.showContextMenu({ + getActions: () => Separator.join(...menu.getActions({ arg: marshalledContext, shouldForwardArgs: true }).map(([, actions]) => actions)), + getAnchor: () => new StandardMouseEvent(getActiveWindow(), e), + getActionsContext: () => marshalledContext + }); + + menu.dispose(); } private _showSessionsPicker(): void { const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { - overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) + overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource) }); picker.pickAgentSession(); } - - private _openChanges(): void { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { - return; - } - - this.commandService.executeCommand(ViewAllSessionChangesAction.ID, activeSession.resource); - } } /** @@ -378,14 +393,14 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben // Register the submenu item in the Agent Sessions command center this._register(MenuRegistry.appendMenuItem(Menus.CommandCenter, { - submenu: Menus.TitleBarControlMenu, + submenu: Menus.TitleBarSessionTitle, title: localize('agentSessionsControl', "Agent Sessions"), order: 101, - when: IsAuxiliaryWindowContext.negate() + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.negate(), SessionsWelcomeVisibleContext.negate()) })); // Register a placeholder action so the submenu appears - this._register(MenuRegistry.appendMenuItem(Menus.TitleBarControlMenu, { + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarSessionTitle, { command: { id: FocusAgentSessionsAction.id, title: localize('showSessions', "Show Sessions"), @@ -395,7 +410,7 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben when: IsAuxiliaryWindowContext.negate() })); - this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarControlMenu, (action, options) => { + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts deleted file mode 100644 index 9bef481d40592..0000000000000 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ /dev/null @@ -1,357 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import '../../../browser/media/sidebarActionButton.css'; -import './media/customizationsToolbar.css'; -import './media/sessionsViewPane.css'; -import * as DOM from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { IViewPaneOptions, IViewPaneLocationColors, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; -import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js'; -import { sessionsSidebarBackground } from '../../../common/theme.js'; -import { SessionsCategories } from '../../../common/categories.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; -import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { ISessionsManagementService } from './sessionsManagementService.js'; -import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount } from './customizationCounts.js'; - -const $ = DOM.$; -export const SessionsViewId = 'agentic.workbench.view.sessionsView'; -const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); - -const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; - -export class AgenticSessionsViewPane extends ViewPane { - - private viewPaneContainer: HTMLElement | undefined; - private sessionsControlContainer: HTMLElement | undefined; - sessionsControl: AgentSessionsControl | undefined; - private aiCustomizationContainer: HTMLElement | undefined; - - constructor( - options: IViewPaneOptions, - @IKeybindingService keybindingService: IKeybindingService, - @IContextMenuService contextMenuService: IContextMenuService, - @IConfigurationService configurationService: IConfigurationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @IInstantiationService instantiationService: IInstantiationService, - @IOpenerService openerService: IOpenerService, - @IThemeService themeService: IThemeService, - @IHoverService hoverService: IHoverService, - @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IStorageService private readonly storageService: IStorageService, - @IPromptsService private readonly promptsService: IPromptsService, - @IMcpService private readonly mcpService: IMcpService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, - ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - } - - protected override renderBody(parent: HTMLElement): void { - super.renderBody(parent); - - this.viewPaneContainer = parent; - this.viewPaneContainer.classList.add('agent-sessions-viewpane'); - - this.createControls(parent); - } - - protected override getLocationBasedColors(): IViewPaneLocationColors { - const colors = super.getLocationBasedColors(); - return { - ...colors, - background: sessionsSidebarBackground, - listOverrideStyles: { - ...colors.listOverrideStyles, - listBackground: sessionsSidebarBackground, - } - }; - } - - private createControls(parent: HTMLElement): void { - const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); - - // Sessions Filter (actions go to view title bar via menu registration) - const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, - groupResults: () => AgentSessionsGrouping.Date, - allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], - providerLabelOverrides: new Map([ - [AgentSessionProviders.Background, localize('chat.session.providerLabel.local', "Local")], - ]), - })); - - // Sessions section (top, fills available space) - const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); - - // Sessions content container - const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); - - // New Session Button - const newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); - const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); - newSessionButton.label = localize('newSession', "New Session"); - this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSessionView())); - - // Keybinding hint inside the button - const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_CHAT); - if (keybinding) { - const keybindingHint = DOM.append(newSessionButton.element, $('span.new-session-keybinding-hint')); - keybindingHint.textContent = keybinding.getLabel() ?? ''; - } - - // Sessions Control - this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); - const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { - source: 'agentSessionsViewPane', - filter: sessionsFilter, - overrideStyles: this.getLocationBasedColors().listOverrideStyles, - disableHover: true, - getHoverPosition: () => this.getSessionHoverPosition(), - trackActiveEditorSession: () => true, - collapseOlderSections: () => true, - overrideSessionOpen: (resource, openOptions) => this.activeSessionService.openSession(resource, openOptions), - })); - this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); - - // Listen to tree updates and restore selection if nothing is selected - this._register(sessionsControl.onDidUpdate(() => { - if (!sessionsControl.hasFocusOrSelection()) { - this.restoreLastSelectedSession(); - } - })); - - // When the active session changes, select it in the tree - this._register(autorun(reader => { - const activeSession = this.activeSessionService.activeSession.read(reader); - if (activeSession) { - if (!sessionsControl.reveal(activeSession.resource)) { - sessionsControl.clearFocus(); - } - } else { - sessionsControl.clearFocus(); // clear selection when a new session is created - } - })); - - // AI Customization toolbar (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); - this.createAICustomizationShortcuts(this.aiCustomizationContainer); - } - - private restoreLastSelectedSession(): void { - const activeSession = this.activeSessionService.getActiveSession(); - if (activeSession && this.sessionsControl) { - this.sessionsControl.reveal(activeSession.resource); - } - } - - private createAICustomizationShortcuts(container: HTMLElement): void { - // Get initial collapsed state - const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); - - container.classList.add('ai-customization-toolbar'); - if (isCollapsed) { - container.classList.add('collapsed'); - } - - // Header (clickable to toggle) - const header = DOM.append(container, $('.ai-customization-header')); - header.classList.toggle('collapsed', isCollapsed); - - const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); - const headerButton = this._register(new Button(headerButtonContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); - headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); - headerButton.label = localize('customizations', "CUSTOMIZATIONS"); - - const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); - const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); - const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); - chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Toolbar container - const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); - - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - telemetrySource: 'sidebarCustomizations', - })); - - let updateCountRequestId = 0; - const updateHeaderTotalCount = async () => { - const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService); - if (requestId !== updateCountRequestId) { - return; - } - - headerTotalCount.classList.toggle('hidden', totalCount === 0); - headerTotalCount.textContent = `${totalCount}`; - }; - - this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); - this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - updateHeaderTotalCount(); - })); - updateHeaderTotalCount(); - - // Toggle collapse on header click - const transitionListener = this._register(new MutableDisposable()); - const toggleCollapse = () => { - const collapsed = container.classList.toggle('collapsed'); - header.classList.toggle('collapsed', collapsed); - this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - headerButton.element.setAttribute('aria-expanded', String(!collapsed)); - chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Re-layout after the transition so sessions control gets the right height - transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { - transitionListener.clear(); - if (this.viewPaneContainer) { - const { offsetHeight, offsetWidth } = this.viewPaneContainer; - this.layoutBody(offsetHeight, offsetWidth); - } - }); - }; - - this._register(headerButton.onDidClick(() => toggleCollapse())); - } - - private getSessionHoverPosition(): HoverPosition { - const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); - const sideBarPosition = this.layoutService.getSideBarPosition(); - - return { - [ViewContainerLocation.Sidebar]: sideBarPosition === 0 ? HoverPosition.RIGHT : HoverPosition.LEFT, - [ViewContainerLocation.AuxiliaryBar]: sideBarPosition === 0 ? HoverPosition.LEFT : HoverPosition.RIGHT, - [ViewContainerLocation.ChatBar]: HoverPosition.RIGHT, - [ViewContainerLocation.Panel]: HoverPosition.ABOVE - }[viewLocation ?? ViewContainerLocation.AuxiliaryBar]; - } - - protected override layoutBody(height: number, width: number): void { - super.layoutBody(height, width); - - if (!this.sessionsControl || !this.sessionsControlContainer) { - return; - } - - this.sessionsControl.layout(this.sessionsControlContainer.offsetHeight, width); - } - - override focus(): void { - super.focus(); - - this.sessionsControl?.focus(); - } - - refresh(): void { - this.sessionsControl?.refresh(); - } - - openFind(): void { - this.sessionsControl?.openFind(); - } -} - -// Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window -KeybindingsRegistry.registerKeybindingRule({ - id: ACTION_ID_NEW_CHAT, - weight: KeybindingWeight.WorkbenchContrib + 1, - primary: KeyMod.CtrlCmd | KeyCode.KeyN, -}); - -MenuRegistry.appendMenuItem(MenuId.ViewTitle, { - submenu: SessionsViewFilterSubMenu, - title: localize2('filterAgentSessions', "Filter Sessions"), - group: 'navigation', - order: 3, - icon: Codicon.filter, - when: ContextKeyExpr.equals('view', SessionsViewId) -} satisfies ISubmenuItem); - -registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { - constructor() { - super({ - id: 'sessionsView.refresh', - title: localize2('refresh', "Refresh Sessions"), - icon: Codicon.refresh, - f1: true, - category: SessionsCategories.Sessions, - }); - } - override run(accessor: ServicesAccessor) { - const viewsService = accessor.get(IViewsService); - const view = viewsService.getViewWithId(SessionsViewId); - return view?.sessionsControl?.refresh(); - } -}); - -registerAction2(class FindAgentSessionInViewerAction extends Action2 { - - constructor() { - super({ - id: 'sessionsView.find', - title: localize2('find', "Find Session"), - icon: Codicon.search, - menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', - order: 2, - when: ContextKeyExpr.equals('view', SessionsViewId), - }] - }); - } - - override run(accessor: ServicesAccessor) { - const viewsService = accessor.get(IViewsService); - const view = viewsService.getViewWithId(SessionsViewId); - return view?.sessionsControl?.openFind(); - } -}); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts new file mode 100644 index 0000000000000..fe7ec83606662 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -0,0 +1,1198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../media/sessionsList.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; +import { IObjectTreeElement, ITreeNode, ITreeRenderer, ITreeContextMenuEvent, ObjectTreeElementCollapseState } from '../../../../../base/browser/ui/tree/tree.js'; +import { RenderIndentGuides, TreeFindMode } from '../../../../../base/browser/ui/tree/abstractTree.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { FuzzyScore } from '../../../../../base/common/filters.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { fromNow } from '../../../../../base/common/date.js'; +import { localize } from '../../../../../nls.js'; +import { MenuId, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; +import { IStyleOverride, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionData, ISessionWorkspace, SessionStatus } from '../../common/sessionData.js'; +import { ISessionsProvidersService } from '../sessionsProvidersService.js'; +import { AgentSessionApprovalModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { Separator } from '../../../../../base/common/actions.js'; + +const $ = DOM.$; + +export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); +export const SessionItemContextMenuId = new MenuId('SessionItemContextMenu'); +export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); +export const IsSessionArchivedContext = new RawContextKey('sessionItem.isArchived', false); +export const IsSessionReadContext = new RawContextKey('sessionItem.isRead', true); + +//#region Types + +export enum SessionsGrouping { + Repository = 'repository', + Date = 'date', +} + +export enum SessionsSorting { + Created = 'created', + Updated = 'updated', +} + +export interface ISessionSection { + readonly id: string; + readonly label: string; + readonly sessions: ISessionData[]; +} + +export interface ISessionShowMore { + readonly showMore: true; + readonly sectionLabel: string; + readonly remainingCount: number; +} + +export type SessionListItem = ISessionData | ISessionSection | ISessionShowMore; + +function isSessionSection(item: SessionListItem): item is ISessionSection { + return 'sessions' in item && Array.isArray((item as ISessionSection).sessions); +} + +function isSessionShowMore(item: SessionListItem): item is ISessionShowMore { + return 'showMore' in item && (item as ISessionShowMore).showMore === true; +} + +//#endregion + +//#region Tree Delegate + +class SessionsTreeDelegate implements IListVirtualDelegate { + private static readonly ITEM_HEIGHT = 54; + private static readonly SECTION_HEIGHT = 26; + private static readonly SHOW_MORE_HEIGHT = 26; + + constructor(private readonly _approvalModel?: AgentSessionApprovalModel) { } + + getHeight(element: SessionListItem): number { + if (isSessionSection(element)) { + return SessionsTreeDelegate.SECTION_HEIGHT; + } + if (isSessionShowMore(element)) { + return SessionsTreeDelegate.SHOW_MORE_HEIGHT; + } + + let height = SessionsTreeDelegate.ITEM_HEIGHT; + if (this._approvalModel) { + const approval = this._approvalModel.getApproval(element.resource).get(); + if (approval) { + height += SessionItemRenderer.getApprovalRowHeight(approval.label); + } + } + return height; + } + + hasDynamicHeight(element: SessionListItem): boolean { + return !!this._approvalModel && !isSessionSection(element) && !isSessionShowMore(element); + } + + getTemplateId(element: SessionListItem): string { + if (isSessionSection(element)) { + return SessionSectionRenderer.TEMPLATE_ID; + } + if (isSessionShowMore(element)) { + return SessionShowMoreRenderer.TEMPLATE_ID; + } + return SessionItemRenderer.TEMPLATE_ID; + } +} + +//#endregion + +//#region Session Item Renderer + +interface ISessionItemTemplate { + readonly container: HTMLElement; + readonly iconContainer: HTMLElement; + readonly title: HTMLElement; + readonly titleToolbar: MenuWorkbenchToolBar; + readonly detailsRow: HTMLElement; + readonly approvalRow: HTMLElement; + readonly approvalLabel: HTMLElement; + readonly approvalButtonContainer: HTMLElement; + readonly contextKeyService: IContextKeyService; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +class SessionItemRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-item'; + readonly templateId = SessionItemRenderer.TEMPLATE_ID; + + private static readonly APPROVAL_ROW_MAX_LINES = 3; + private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18; + private static readonly _APPROVAL_ROW_OVERHEAD = 14; + + static getApprovalRowHeight(label: string): number { + const lineCount = Math.min(label.split(/\\r?\\n/).length, SessionItemRenderer.APPROVAL_ROW_MAX_LINES); + return lineCount * SessionItemRenderer._APPROVAL_ROW_LINE_HEIGHT + SessionItemRenderer._APPROVAL_ROW_OVERHEAD; + } + + private readonly _onDidChangeItemHeight = new Emitter(); + readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + + constructor( + private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISessionData) => boolean }, + private readonly approvalModel: AgentSessionApprovalModel | undefined, + private readonly instantiationService: IInstantiationService, + private readonly contextKeyService: IContextKeyService, + private readonly markdownRendererService: IMarkdownRendererService, + ) { } + + renderTemplate(container: HTMLElement): ISessionItemTemplate { + const disposables = new DisposableStore(); + const elementDisposables = disposables.add(new DisposableStore()); + + container.classList.add('session-item'); + + const iconContainer = DOM.append(container, $('.session-icon')); + const mainCol = DOM.append(container, $('.session-main')); + const titleRow = DOM.append(mainCol, $('.session-title-row')); + const title = DOM.append(titleRow, $('.session-title')); + const titleToolbarContainer = DOM.append(titleRow, $('.session-title-toolbar')); + const detailsRow = DOM.append(mainCol, $('.session-details-row')); + + // Approval row + const approvalRow = DOM.append(mainCol, $('.session-approval-row')); + const approvalLabel = DOM.append(approvalRow, $('span.session-approval-label')); + const approvalButtonContainer = DOM.append(approvalRow, $('.session-approval-button')); + + const contextKeyService = disposables.add(this.contextKeyService.createScoped(container)); + const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + const titleToolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, titleToolbarContainer, SessionItemToolbarMenuId, { + menuOptions: { shouldForwardArgs: true }, + })); + + return { container, iconContainer, title, titleToolbar, detailsRow, approvalRow, approvalLabel, approvalButtonContainer, contextKeyService, disposables, elementDisposables }; + } + + renderElement(node: ITreeNode, _index: number, template: ISessionItemTemplate): void { + const element = node.element; + if (isSessionSection(element) || isSessionShowMore(element)) { + return; + } + this.renderSession(element, template); + } + + private renderSession(element: ISessionData, template: ISessionItemTemplate): void { + template.elementDisposables.clear(); + + // Toolbar context + template.titleToolbar.context = element; + + // Context key: isPinned + IsSessionPinnedContext.bindTo(template.contextKeyService).set(this.options.isPinned(element)); + IsSessionArchivedContext.bindTo(template.contextKeyService).set(element.isArchived.get()); + IsSessionReadContext.bindTo(template.contextKeyService).set(element.isRead.get()); + + // Archived styling — reactive + template.elementDisposables.add(autorun(reader => { + template.container.classList.toggle('archived', element.isArchived.read(reader)); + })); + + // Icon — reactive based on status, read state, and PR + template.elementDisposables.add(autorun(reader => { + const sessionStatus = element.status.read(reader); + const isRead = element.isRead.read(reader); + const isArchived = element.isArchived.read(reader); + const pullRequestUri = element.pullRequestUri.read(reader); + DOM.clearNode(template.iconContainer); + const icon = this.getStatusIcon(sessionStatus, isRead, isArchived, !!pullRequestUri, element.icon); + DOM.append(template.iconContainer, $(`span${ThemeIcon.asCSSSelector(icon)}`)); + template.iconContainer.classList.toggle('session-icon-pulse', sessionStatus === SessionStatus.NeedsInput); + template.iconContainer.classList.toggle('session-icon-active', sessionStatus === SessionStatus.InProgress); + template.iconContainer.classList.toggle('session-icon-error', sessionStatus === SessionStatus.Error); + template.iconContainer.classList.toggle('session-icon-unread', !isRead && !isArchived && sessionStatus !== SessionStatus.InProgress && sessionStatus !== SessionStatus.NeedsInput && sessionStatus !== SessionStatus.Error); + template.iconContainer.classList.toggle('session-icon-pr', !!pullRequestUri && sessionStatus === SessionStatus.Completed); + })); + + // Title — reactive + template.elementDisposables.add(autorun(reader => { + const titleText = element.title.read(reader); + template.title.textContent = titleText; + })); + + // Details row — reactive: badge · diff stats · time + const timeDisposable = template.elementDisposables.add(new MutableDisposable()); + template.elementDisposables.add(autorun(reader => { + const sessionStatus = element.status.read(reader); + const changes = element.changes.read(reader); + const updatedAt = element.updatedAt.read(reader); + const workspace = element.workspace.read(reader); + const description = element.description.read(reader); + + // Clear and rebuild details row + DOM.clearNode(template.detailsRow); + const parts: HTMLElement[] = []; + + // Session type icon in details row + const typeIconEl = DOM.append(template.detailsRow, $('span.session-details-icon')); + DOM.append(typeIconEl, $(`span${ThemeIcon.asCSSSelector(element.icon)}`)); + parts.push(typeIconEl); + + // Workspace badge — show when not grouped by repository + if (workspace && this.options.grouping() !== SessionsGrouping.Repository) { + const badgeLabel = this.getWorkspaceBadgeLabel(workspace); + if (badgeLabel) { + const badgeEl = DOM.append(template.detailsRow, $('span.session-badge')); + badgeEl.textContent = badgeLabel; + parts.push(badgeEl); + } + } + + // Diff stats + if (changes.length > 0 && sessionStatus !== SessionStatus.InProgress) { + let insertions = 0; + let deletions = 0; + for (const change of changes) { + insertions += change.insertions; + deletions += change.deletions; + } + if (insertions > 0 || deletions > 0) { + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const diffEl = DOM.append(template.detailsRow, $('span.session-diff')); + DOM.append(diffEl, $('span.session-diff-added')).textContent = `+${insertions}`; + DOM.append(diffEl, $('span')).textContent = ' '; + DOM.append(diffEl, $('span.session-diff-removed')).textContent = `-${deletions}`; + parts.push(diffEl); + } + } + + // Status description + if (sessionStatus === SessionStatus.InProgress) { + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const statusEl = DOM.append(template.detailsRow, $('span.session-description')); + statusEl.textContent = description ?? localize('working', "Working..."); + parts.push(statusEl); + } else if (sessionStatus === SessionStatus.NeedsInput) { + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const statusEl = DOM.append(template.detailsRow, $('span.session-description')); + statusEl.textContent = description ?? localize('needsInput', "Input needed"); + parts.push(statusEl); + } else if (sessionStatus === SessionStatus.Error) { + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const statusEl = DOM.append(template.detailsRow, $('span.session-description')); + statusEl.textContent = localize('failed', "Failed"); + parts.push(statusEl); + } + + // Timestamp — always visible + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const timeEl = DOM.append(template.detailsRow, $('span.session-time')); + const formatTime = () => { + const seconds = Math.round((Date.now() - updatedAt.getTime()) / 1000); + return seconds < 60 ? localize('secondsDuration', "now") : fromNow(updatedAt, true); + }; + timeEl.textContent = formatTime(); + const targetWindow = DOM.getWindow(timeEl); + const interval = targetWindow.setInterval(() => { + timeEl.textContent = formatTime(); + }, 60_000); + timeDisposable.value = { dispose: () => targetWindow.clearInterval(interval) }; + })); + + // Approval row — reactive + if (this.approvalModel) { + this.renderApprovalRow(element, template); + } + } + + private renderApprovalRow(element: ISessionData, template: ISessionItemTemplate): void { + if (!this.approvalModel) { + return; + } + + const approvalModel = this.approvalModel; + const initialInfo = approvalModel.getApproval(element.resource).get(); + let wasVisible = !!initialInfo; + template.approvalRow.classList.toggle('visible', wasVisible); + + const buttonStore = template.elementDisposables.add(new DisposableStore()); + + template.elementDisposables.add(autorun(reader => { + buttonStore.clear(); + + const info = approvalModel.getApproval(element.resource).read(reader); + const visible = !!info; + + template.approvalRow.classList.toggle('visible', visible); + + if (info) { + // Render up to 3 lines as separate code blocks + const lines = info.label.split('\n'); + const maxLines = SessionItemRenderer.APPROVAL_ROW_MAX_LINES; + const visibleLines = lines.slice(0, maxLines); + if (lines.length > maxLines) { + visibleLines[maxLines - 1] = `${visibleLines[maxLines - 1]} \u2026`; + } + const langId = info.languageId ?? 'json'; + const labelContent = new MarkdownString(); + for (const line of visibleLines) { + labelContent.appendCodeblock(langId, line); + } + + template.approvalLabel.textContent = ''; + buttonStore.add(this.markdownRendererService.render(labelContent, {}, template.approvalLabel)); + + template.approvalButtonContainer.textContent = ''; + const button = buttonStore.add(new Button(template.approvalButtonContainer, { + title: localize('allowActionOnce', "Allow once"), + secondary: true, + ...defaultButtonStyles + })); + button.label = localize('allowAction', "Allow"); + buttonStore.add(button.onDidClick(() => info.confirm())); + } + + if (wasVisible !== visible) { + wasVisible = visible; + this._onDidChangeItemHeight.fire(element); + } + })); + } + + private getStatusIcon(status: SessionStatus, isRead: boolean, isArchived: boolean, hasPR: boolean, _defaultIcon: ThemeIcon): ThemeIcon { + switch (status) { + case SessionStatus.InProgress: return Codicon.sessionInProgress; + case SessionStatus.NeedsInput: return Codicon.circleFilled; + case SessionStatus.Error: return Codicon.error; + default: + if (hasPR) { + return Codicon.gitPullRequest; + } + if (!isRead && !isArchived) { + return Codicon.circleFilled; + } + // Status-only: show small dot for read sessions + return Codicon.circleSmallFilled; + } + } + + private getWorkspaceBadgeLabel(workspace: ISessionWorkspace): string | undefined { + // For GitHub remote sessions, extract owner/name from the repository URI path + const repo = workspace.repositories[0]; + if (repo?.uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = repo.uri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return `${parts[0]}/${parts[1]}`; + } + } + + return workspace.label; + } + + + + disposeElement(node: ITreeNode, _index: number, template: ISessionItemTemplate): void { + template.elementDisposables.clear(); + } + + disposeTemplate(template: ISessionItemTemplate): void { + template.disposables.dispose(); + } +} + +//#endregion + +//#region Section Header Renderer + +interface ISessionSectionTemplate { + readonly container: HTMLElement; + readonly label: HTMLElement; + readonly count: HTMLElement; +} + +class SessionSectionRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-section'; + readonly templateId = SessionSectionRenderer.TEMPLATE_ID; + + constructor(private readonly hideSectionCount: boolean) { } + + renderTemplate(container: HTMLElement): ISessionSectionTemplate { + container.classList.add('session-section'); + const label = DOM.append(container, $('span.session-section-label')); + const count = DOM.append(container, $('span.session-section-count')); + return { container, label, count }; + } + + renderElement(node: ITreeNode, _index: number, template: ISessionSectionTemplate): void { + const element = node.element; + if (!isSessionSection(element)) { + return; + } + template.label.textContent = element.label; + if (this.hideSectionCount) { + template.count.textContent = ''; + template.count.style.display = 'none'; + } else { + template.count.textContent = String(element.sessions.length); + template.count.style.display = ''; + } + } + + disposeTemplate(_template: ISessionSectionTemplate): void { } +} + +//#endregion + +//#region Show More Renderer + +class SessionShowMoreRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-show-more'; + readonly templateId = SessionShowMoreRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): HTMLElement { + container.classList.add('session-show-more'); + return DOM.append(container, $('span.session-show-more-label')); + } + + renderElement(node: ITreeNode, _index: number, template: HTMLElement): void { + const element = node.element; + if (!isSessionShowMore(element)) { + return; + } + template.textContent = localize('showMoreCompact', "+{0} more", element.remainingCount); + } + + disposeTemplate(_template: HTMLElement): void { } +} + +//#region Accessibility + +class SessionsAccessibilityProvider { + getWidgetAriaLabel(): string { + return localize('sessionsList', "Sessions"); + } + + getAriaLabel(element: SessionListItem): string | null { + if (isSessionSection(element)) { + return `${element.label}, ${element.sessions.length}`; + } + if (isSessionShowMore(element)) { + return localize('showMoreAria', "Show {0} more sessions", element.remainingCount); + } + return element.title.get(); + } +} + +//#endregion + +//#region Sessions List Control + +export interface ISessionsListControlOptions { + readonly overrideStyles?: IStyleOverride; + readonly grouping: () => SessionsGrouping; + readonly sorting: () => SessionsSorting; + onSessionOpen(resource: URI): void; +} + +/** + * @deprecated Use {@link ISessionsListControlOptions} instead. + */ +export type ISessionsListOptions = ISessionsListControlOptions; + +export interface ISessionsList { + readonly element: HTMLElement; + readonly onDidUpdate: Event; + refresh(): void; + reveal(sessionResource: URI): boolean; + clearFocus(): void; + hasFocusOrSelection(): boolean; + setVisible(visible: boolean): void; + layout(height: number, width: number): void; + focus(): void; + update(expandAll?: boolean): void; + openFind(): void; + resetSectionCollapseState(): void; + pinSession(session: ISessionData): void; + unpinSession(session: ISessionData): void; + isSessionPinned(session: ISessionData): boolean; + setSessionTypeExcluded(sessionTypeId: string, excluded: boolean): void; + isSessionTypeExcluded(sessionTypeId: string): boolean; + setStatusExcluded(status: SessionStatus, excluded: boolean): void; + isStatusExcluded(status: SessionStatus): boolean; + setExcludeArchived(exclude: boolean): void; + isExcludeArchived(): boolean; + setExcludeRead(exclude: boolean): void; + isExcludeRead(): boolean; + resetFilters(): void; + setRepositoryGroupCapped(capped: boolean): void; + isRepositoryGroupCapped(): boolean; +} + +export class SessionsList extends Disposable implements ISessionsList { + + private static readonly SECTION_COLLAPSE_STATE_KEY = 'sessionsListControl.sectionCollapseState'; + private static readonly PINNED_SESSIONS_KEY = 'sessionsListControl.pinnedSessions'; + private static readonly EXCLUDED_TYPES_KEY = 'sessionsListControl.excludedSessionTypes'; + private static readonly EXCLUDED_STATUSES_KEY = 'sessionsListControl.excludedStatuses'; + private static readonly EXCLUDE_ARCHIVED_KEY = 'sessionsListControl.excludeArchived'; + private static readonly EXCLUDE_READ_KEY = 'sessionsListControl.excludeRead'; + private static readonly REPO_GROUP_CAPPED_KEY = 'sessionsListControl.repoGroupCapped'; + private static readonly REPO_GROUP_LIMIT = 5; + + private readonly listContainer: HTMLElement; + private readonly tree: WorkbenchObjectTree; + private sessions: ISessionData[] = []; + private visible = true; + private readonly pinnedSessionIds: Set; + private readonly excludedSessionTypes: Set; + private readonly excludedStatuses: Set; + private _excludeArchived: boolean; + private _excludeRead: boolean; + private _repoGroupCapped: boolean; + private readonly _expandedRepoGroups = new Set(); + + private readonly _onDidUpdate = this._register(new Emitter()); + readonly onDidUpdate: Event = this._onDidUpdate.event; + + get element(): HTMLElement { return this.listContainer; } + + constructor( + container: HTMLElement, + private readonly options: ISessionsListControlOptions, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(); + + // Load pinned sessions from storage + this.pinnedSessionIds = this.loadPinnedSessions(); + + // Load excluded session types from storage + this.excludedSessionTypes = this.loadExcludedSessionTypes(); + + // Load excluded statuses from storage + this.excludedStatuses = this.loadExcludedStatuses(); + + // Load archived/read filter state + this._excludeArchived = this.storageService.getBoolean(SessionsList.EXCLUDE_ARCHIVED_KEY, StorageScope.PROFILE, true); + this._excludeRead = this.storageService.getBoolean(SessionsList.EXCLUDE_READ_KEY, StorageScope.PROFILE, false); + this._repoGroupCapped = this.storageService.getBoolean(SessionsList.REPO_GROUP_CAPPED_KEY, StorageScope.PROFILE, true); + + this.listContainer = DOM.append(container, $('.sessions-list-control')); + + const approvalModel = this._register(instantiationService.createInstance(AgentSessionApprovalModel)); + const markdownRendererService = instantiationService.invokeFunction(accessor => accessor.get(IMarkdownRendererService)); + const sessionRenderer = new SessionItemRenderer( + { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s) }, + approvalModel, + instantiationService, + contextKeyService, + markdownRendererService, + ); + + const showMoreRenderer = new SessionShowMoreRenderer(); + + this.tree = this._register(instantiationService.createInstance( + WorkbenchObjectTree, + 'SessionsListTree', + this.listContainer, + new SessionsTreeDelegate(approvalModel), + [ + sessionRenderer, + new SessionSectionRenderer(true /* hideSectionCount */), + showMoreRenderer, + ], + { + accessibilityProvider: new SessionsAccessibilityProvider(), + identityProvider: { + getId: (element: SessionListItem) => { + if (isSessionSection(element)) { + return `section:${element.id}`; + } + if (isSessionShowMore(element)) { + return `show-more:${element.sectionLabel}`; + } + return element.resource.toString(); + } + }, + horizontalScrolling: false, + multipleSelectionSupport: true, + indent: 0, + findWidgetEnabled: true, + defaultFindMode: TreeFindMode.Filter, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (element: SessionListItem) => { + if (isSessionSection(element)) { + return element.label; + } + if (isSessionShowMore(element)) { + return element.sectionLabel; + } + return element.title.get(); + } + }, + overrideStyles: this.options.overrideStyles, + renderIndentGuides: RenderIndentGuides.None, + twistieAdditionalCssClass: () => 'force-no-twistie', + } + )); + + this._register(this.tree.onDidOpen(e => { + const element = e.element; + if (!element) { + return; + } + if (isSessionShowMore(element)) { + this._expandedRepoGroups.add(element.sectionLabel); + this.update(); + return; + } + if (!isSessionSection(element)) { + this.options.onSessionOpen(element.resource); + } + })); + + this._register(sessionRenderer.onDidChangeItemHeight(session => { + if (this.tree.hasElement(session)) { + this.tree.updateElementHeight(session, undefined); + } + })); + + this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); + + this._register(this.tree.onDidChangeCollapseState(e => { + const element = e.node.element; + if (element && isSessionSection(element)) { + this.saveSectionCollapseState(element.id, e.node.collapsed); + } + })); + + this._register(this.sessionsProvidersService.onDidChangeSessions(() => { + if (this.visible) { + this.refresh(); + } + })); + + this.refresh(); + } + + refresh(): void { + this.sessions = this.sessionsProvidersService.getSessions(); + this.update(); + } + + update(expandAll?: boolean): void { + // Filter by session type and status + let filtered = this.sessions; + if (this.excludedSessionTypes.size > 0) { + filtered = filtered.filter(s => !this.excludedSessionTypes.has(s.sessionType)); + } + if (this.excludedStatuses.size > 0) { + filtered = filtered.filter(s => !this.excludedStatuses.has(s.status.get())); + } + if (this._excludeArchived) { + filtered = filtered.filter(s => !s.isArchived.get()); + } + if (this._excludeRead) { + filtered = filtered.filter(s => !s.isRead.get()); + } + + const sorted = this.sortSessions(filtered); + + // Separate pinned and archived sessions + const pinned: ISessionData[] = []; + const archived: ISessionData[] = []; + const regular: ISessionData[] = []; + for (const session of sorted) { + if (this.isSessionPinned(session)) { + pinned.push(session); + } else if (session.isArchived.get()) { + archived.push(session); + } else { + regular.push(session); + } + } + + const grouping = this.options.grouping(); + const sections: ISessionSection[] = []; + + // Add pinned section at the top if there are pinned sessions + if (pinned.length > 0) { + sections.push({ id: 'pinned', label: localize('pinned', "Pinned"), sessions: pinned }); + } + + // Group remaining non-archived sessions + const grouped = grouping === SessionsGrouping.Repository + ? this.groupByRepository(regular) + : this.groupByDate(regular); + sections.push(...grouped); + + // Add archived section at the bottom + if (archived.length > 0) { + sections.push({ id: 'archived', label: localize('archived', "Archived"), sessions: archived }); + } + + const hasTodaySessions = sections.some(s => s.id === 'today' && s.sessions.length > 0); + + const children: IObjectTreeElement[] = sections.map(section => { + const isRepoGroup = grouping === SessionsGrouping.Repository + && section.id !== 'pinned' && section.id !== 'archived'; + const isCapped = isRepoGroup && this._repoGroupCapped + && !this._expandedRepoGroups.has(section.label) + && section.sessions.length > SessionsList.REPO_GROUP_LIMIT; + + let sectionChildren: IObjectTreeElement[]; + if (isCapped) { + const visible = section.sessions.slice(0, SessionsList.REPO_GROUP_LIMIT); + const remainingCount = section.sessions.length - SessionsList.REPO_GROUP_LIMIT; + sectionChildren = [ + ...visible.map(session => ({ element: session as SessionListItem })), + { element: { showMore: true as const, sectionLabel: section.label, remainingCount } }, + ]; + } else { + sectionChildren = section.sessions.map(session => ({ element: session as SessionListItem })); + } + + // Default collapse state for older time sections + let defaultCollapsed: boolean | ObjectTreeElementCollapseState = ObjectTreeElementCollapseState.PreserveOrExpanded; + if (grouping === SessionsGrouping.Date && hasTodaySessions) { + const olderSections = ['yesterday', 'thisWeek', 'older', 'archived']; + if (olderSections.includes(section.id)) { + defaultCollapsed = ObjectTreeElementCollapseState.PreserveOrCollapsed; + } + } + if (section.id === 'archived') { + defaultCollapsed = ObjectTreeElementCollapseState.PreserveOrCollapsed; + } + + return { + element: section as SessionListItem, + collapsible: true, + collapsed: this.getSavedCollapseState(section.id) ?? defaultCollapsed, + children: sectionChildren, + }; + }); + + this.tree.setChildren(null, children); + this._onDidUpdate.fire(); + } + + reveal(sessionResource: URI): boolean { + const resourceStr = sessionResource.toString(); + for (const session of this.sessions) { + if (session.resource.toString() === resourceStr) { + if (this.tree.hasElement(session)) { + if (this.tree.getRelativeTop(session) === null) { + this.tree.reveal(session, 0.5); + } + this.tree.setFocus([session]); + this.tree.setSelection([session]); + return true; + } + } + } + return false; + } + + clearFocus(): void { + this.tree.setFocus([]); + this.tree.setSelection([]); + } + + hasFocusOrSelection(): boolean { + return this.tree.getFocus().length > 0 || this.tree.getSelection().length > 0; + } + + setVisible(visible: boolean): void { + if (this.visible === visible) { + return; + } + this.visible = visible; + if (this.visible) { + this.refresh(); + } + } + + layout(height: number, width: number): void { + this.tree.layout(height, width); + } + + focus(): void { + this.tree.domFocus(); + } + + openFind(): void { + this.tree.openFind(); + } + + // Context menu + + private onContextMenu(e: ITreeContextMenuEvent): void { + const element = e.element; + if (!element || isSessionSection(element) || isSessionShowMore(element)) { + return; + } + + const contextOverlay: [string, boolean | string][] = [ + [IsSessionPinnedContext.key, this.isSessionPinned(element)], + [IsSessionArchivedContext.key, element.isArchived.get()], + [IsSessionReadContext.key, element.isRead.get()], + ['chatSessionType', element.sessionType], + ['chatSessionProviderId', element.providerId], + ]; + + const menu = this.menuService.createMenu(SessionItemContextMenuId, this.contextKeyService.createOverlay(contextOverlay)); + + this.contextMenuService.showContextMenu({ + getActions: () => Separator.join(...menu.getActions({ arg: element, shouldForwardArgs: true }).map(([, actions]) => actions)), + getAnchor: () => e.anchor, + getKeyBinding: (action) => this.keybindingService.lookupKeybinding(action.id) ?? undefined, + }); + + menu.dispose(); + } + + resetSectionCollapseState(): void { + this.storageService.remove(SessionsList.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + } + + // -- Pinning -- + + pinSession(session: ISessionData): void { + this.pinnedSessionIds.add(session.sessionId); + this.savePinnedSessions(); + this.update(); + } + + unpinSession(session: ISessionData): void { + this.pinnedSessionIds.delete(session.sessionId); + this.savePinnedSessions(); + this.update(); + } + + isSessionPinned(session: ISessionData): boolean { + return this.pinnedSessionIds.has(session.sessionId); + } + + private loadPinnedSessions(): Set { + const raw = this.storageService.get(SessionsList.PINNED_SESSIONS_KEY, StorageScope.PROFILE); + if (raw) { + try { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + return new Set(arr); + } + } catch { + // ignore corrupt data + } + } + return new Set(); + } + + private savePinnedSessions(): void { + if (this.pinnedSessionIds.size === 0) { + this.storageService.remove(SessionsList.PINNED_SESSIONS_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(SessionsList.PINNED_SESSIONS_KEY, JSON.stringify([...this.pinnedSessionIds]), StorageScope.PROFILE, StorageTarget.USER); + } + } + + // -- Session type filtering -- + + setSessionTypeExcluded(sessionTypeId: string, excluded: boolean): void { + if (excluded) { + this.excludedSessionTypes.add(sessionTypeId); + } else { + this.excludedSessionTypes.delete(sessionTypeId); + } + this.saveExcludedSessionTypes(); + this.update(); + } + + isSessionTypeExcluded(sessionTypeId: string): boolean { + return this.excludedSessionTypes.has(sessionTypeId); + } + + private loadExcludedSessionTypes(): Set { + const raw = this.storageService.get(SessionsList.EXCLUDED_TYPES_KEY, StorageScope.PROFILE); + if (raw) { + try { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + return new Set(arr); + } + } catch { + // ignore corrupt data + } + } + return new Set(); + } + + private saveExcludedSessionTypes(): void { + if (this.excludedSessionTypes.size === 0) { + this.storageService.remove(SessionsList.EXCLUDED_TYPES_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(SessionsList.EXCLUDED_TYPES_KEY, JSON.stringify([...this.excludedSessionTypes]), StorageScope.PROFILE, StorageTarget.USER); + } + } + + // -- Status filtering -- + + setStatusExcluded(status: SessionStatus, excluded: boolean): void { + if (excluded) { + this.excludedStatuses.add(status); + } else { + this.excludedStatuses.delete(status); + } + this.saveExcludedStatuses(); + this.update(); + } + + isStatusExcluded(status: SessionStatus): boolean { + return this.excludedStatuses.has(status); + } + + private loadExcludedStatuses(): Set { + const raw = this.storageService.get(SessionsList.EXCLUDED_STATUSES_KEY, StorageScope.PROFILE); + if (raw) { + try { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + return new Set(arr); + } + } catch { + // ignore corrupt data + } + } + return new Set(); + } + + private saveExcludedStatuses(): void { + if (this.excludedStatuses.size === 0) { + this.storageService.remove(SessionsList.EXCLUDED_STATUSES_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(SessionsList.EXCLUDED_STATUSES_KEY, JSON.stringify([...this.excludedStatuses]), StorageScope.PROFILE, StorageTarget.USER); + } + } + + // -- Archived / Read filtering -- + + setExcludeArchived(exclude: boolean): void { + this._excludeArchived = exclude; + this.storageService.store(SessionsList.EXCLUDE_ARCHIVED_KEY, exclude, StorageScope.PROFILE, StorageTarget.USER); + this.update(); + } + + isExcludeArchived(): boolean { + return this._excludeArchived; + } + + setExcludeRead(exclude: boolean): void { + this._excludeRead = exclude; + this.storageService.store(SessionsList.EXCLUDE_READ_KEY, exclude, StorageScope.PROFILE, StorageTarget.USER); + this.update(); + } + + isExcludeRead(): boolean { + return this._excludeRead; + } + + resetFilters(): void { + this.excludedSessionTypes.clear(); + this.saveExcludedSessionTypes(); + this.excludedStatuses.clear(); + this.saveExcludedStatuses(); + this._excludeArchived = true; + this.storageService.store(SessionsList.EXCLUDE_ARCHIVED_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + this._excludeRead = false; + this.storageService.store(SessionsList.EXCLUDE_READ_KEY, false, StorageScope.PROFILE, StorageTarget.USER); + this._repoGroupCapped = true; + this.storageService.store(SessionsList.REPO_GROUP_CAPPED_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + this._expandedRepoGroups.clear(); + this.update(); + } + + // Repository group capping + + setRepositoryGroupCapped(capped: boolean): void { + this._repoGroupCapped = capped; + this.storageService.store(SessionsList.REPO_GROUP_CAPPED_KEY, capped, StorageScope.PROFILE, StorageTarget.USER); + if (capped) { + this._expandedRepoGroups.clear(); + } + this.update(); + } + + isRepositoryGroupCapped(): boolean { + return this._repoGroupCapped; + } + + // -- Section collapse persistence -- + + private getSavedCollapseState(sectionId: string): boolean | undefined { + const raw = this.storageService.get(SessionsList.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + if (raw) { + try { + const state: Record = JSON.parse(raw); + if (typeof state[sectionId] === 'boolean') { + return state[sectionId]; + } + } catch { + // ignore corrupt data + } + } + return undefined; + } + + private saveSectionCollapseState(sectionId: string, collapsed: boolean): void { + let state: Record = {}; + const raw = this.storageService.get(SessionsList.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + state = parsed; + } + } catch { + // ignore corrupt data + } + } + state[sectionId] = collapsed; + this.storageService.store(SessionsList.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); + } + + // -- Sorting -- + + private sortSessions(sessions: ISessionData[]): ISessionData[] { + const sorting = this.options.sorting(); + return [...sessions].sort((a, b) => { + // Prioritize active sessions (NeedsInput first, then InProgress) + const aStatus = a.status.get(); + const bStatus = b.status.get(); + const aActive = aStatus === SessionStatus.NeedsInput || aStatus === SessionStatus.InProgress; + const bActive = bStatus === SessionStatus.NeedsInput || bStatus === SessionStatus.InProgress; + if (aActive && !bActive) { + return -1; + } + if (!aActive && bActive) { + return 1; + } + // Among active sessions, NeedsInput comes before InProgress + if (aActive && bActive) { + if (aStatus === SessionStatus.NeedsInput && bStatus !== SessionStatus.NeedsInput) { + return -1; + } + if (aStatus !== SessionStatus.NeedsInput && bStatus === SessionStatus.NeedsInput) { + return 1; + } + } + + // Sort by time + if (sorting === SessionsSorting.Updated) { + return b.updatedAt.get().getTime() - a.updatedAt.get().getTime(); + } + return b.createdAt.getTime() - a.createdAt.getTime(); + }); + } + + // -- Grouping -- + + private groupByRepository(sessions: ISessionData[]): ISessionSection[] { + const groups = new Map(); + const order: string[] = []; + for (const session of sessions) { + const workspace = session.workspace.get(); + const label = workspace?.label ?? localize('noProject', "No Project"); + let group = groups.get(label); + if (!group) { + group = []; + groups.set(label, group); + order.push(label); + } + group.push(session); + } + + return order.map(label => ({ + id: `repo:${label}`, + label, + sessions: groups.get(label)!, + })); + } + + private groupByDate(sessions: ISessionData[]): ISessionSection[] { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 86_400_000; + const startOfWeek = startOfToday - 7 * 86_400_000; + + const today: ISessionData[] = []; + const yesterday: ISessionData[] = []; + const week: ISessionData[] = []; + const older: ISessionData[] = []; + + const sorting = this.options.sorting(); + for (const session of sessions) { + const time = sorting === SessionsSorting.Updated + ? session.updatedAt.get().getTime() + : session.createdAt.getTime(); + + if (time >= startOfToday) { + today.push(session); + } else if (time >= startOfYesterday) { + yesterday.push(session); + } else if (time >= startOfWeek) { + week.push(session); + } else { + older.push(session); + } + } + + const sections: ISessionSection[] = []; + const addGroup = (id: string, label: string, groupSessions: ISessionData[]) => { + if (groupSessions.length > 0) { + sections.push({ id, label, sessions: groupSessions }); + } + }; + + addGroup('today', localize('today', "Today"), today); + addGroup('yesterday', localize('yesterday', "Yesterday"), yesterday); + addGroup('thisWeek', localize('lastSevenDays', "Last 7 Days"), week); + addGroup('older', localize('older', "Older"), older); + + return sections; + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts new file mode 100644 index 0000000000000..8bd0423cf0696 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -0,0 +1,388 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../media/sessionsViewPane.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, IViewPaneLocationColors, ViewPane } from '../../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService } from '../../../../../workbench/common/views.js'; +import { sessionsSidebarBackground } from '../../../../common/theme.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { localize } from '../../../../../nls.js'; +import { SessionsList, SessionsGrouping, SessionsSorting } from './sessionsList.js'; +import { SessionStatus } from '../../common/sessionData.js'; +import { ISessionsManagementService } from '../sessionsManagementService.js'; +import { AICustomizationShortcutsWidget } from '../aiCustomizationShortcutsWidget.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; + +const $ = DOM.$; +export const SessionsViewId = 'sessions.workbench.view.sessionsView'; +const ACTION_ID_NEW_SESSION = 'workbench.action.chat.newChat'; +const GROUPING_STORAGE_KEY = 'sessionsViewPane.grouping'; +const SORTING_STORAGE_KEY = 'sessionsViewPane.sorting'; + +export const SessionsViewFilterSubMenu = new MenuId('SessionsViewPaneFilterSubMenu'); +export const SessionsViewFilterOptionsSubMenu = new MenuId('SessionsViewPaneFilterOptionsSubMenu'); +export const SessionsViewGroupingContext = new RawContextKey('sessionsViewPane.grouping', SessionsGrouping.Repository); +export const SessionsViewSortingContext = new RawContextKey('sessionsViewPane.sorting', SessionsSorting.Created); +export const IsRepositoryGroupCappedContext = new RawContextKey('sessionsViewPane.repoGroupCapped', true); + +export class SessionsView extends ViewPane { + + private viewPaneContainer: HTMLElement | undefined; + private sessionsControlContainer: HTMLElement | undefined; + sessionsControl: SessionsList | undefined; + private currentGrouping: SessionsGrouping = SessionsGrouping.Repository; + private currentSorting: SessionsSorting = SessionsSorting.Created; + private groupingContextKey: IContextKey | undefined; + private sortingContextKey: IContextKey | undefined; + private readonly filterContextKeys = new Map; getDefault: () => boolean }>(); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IHostService private readonly hostService: IHostService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Restore persisted grouping + const storedGrouping = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); + if (storedGrouping && Object.values(SessionsGrouping).includes(storedGrouping as SessionsGrouping)) { + this.currentGrouping = storedGrouping as SessionsGrouping; + } + + // Restore persisted sorting + const storedSorting = this.storageService.get(SORTING_STORAGE_KEY, StorageScope.PROFILE); + if (storedSorting && Object.values(SessionsSorting).includes(storedSorting as SessionsSorting)) { + this.currentSorting = storedSorting as SessionsSorting; + } + + // Ensure context keys reflect restored state immediately + this.groupingContextKey = SessionsViewGroupingContext.bindTo(contextKeyService); + this.groupingContextKey.set(this.currentGrouping); + this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); + this.sortingContextKey.set(this.currentSorting); + } + + protected override renderBody(parent: HTMLElement): void { + super.renderBody(parent); + + this.viewPaneContainer = parent; + this.viewPaneContainer.classList.add('agent-sessions-viewpane'); + + this.createControls(parent); + } + + protected override getLocationBasedColors(): IViewPaneLocationColors { + const colors = super.getLocationBasedColors(); + return { + ...colors, + background: sessionsSidebarBackground, + listOverrideStyles: { + ...colors.listOverrideStyles, + listBackground: sessionsSidebarBackground, + } + }; + } + + private createControls(parent: HTMLElement): void { + const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); + + // Sessions section (top, fills available space) + const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); + + // Sessions content container + const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); + + // New Session Button + const newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); + const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); + newSessionButton.label = localize('newSession', "New Session"); + this._register(newSessionButton.onDidClick(() => this.sessionsManagementService.openNewSessionView())); + + // Keybinding hint inside the button + const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_SESSION); + if (keybinding) { + const keybindingHint = DOM.append(newSessionButton.element, $('span.new-session-keybinding-hint')); + keybindingHint.textContent = keybinding.getLabel() ?? ''; + } + + // Sessions List Control + this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); + const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(SessionsList, this.sessionsControlContainer, { + overrideStyles: this.getLocationBasedColors().listOverrideStyles, + grouping: () => this.currentGrouping, + sorting: () => this.currentSorting, + onSessionOpen: (resource) => this.sessionsManagementService.openSession(resource), + })); + this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); + + // Register session type filter actions (re-register when session types change) + this.registerSessionTypeFilters(sessionsControl); + this._register(this.sessionsManagementService.onDidChangeSessionTypes(() => { + this.registerSessionTypeFilters(sessionsControl); + })); + + // Register status filter actions (static set, registered once) + this.registerStatusFilters(sessionsControl); + + // Refresh sessions when window gets focus to compensate for missing events + this._register(this.hostService.onDidChangeFocus(hasFocus => { + if (hasFocus) { + sessionsControl.refresh(); + } + })); + + // Listen to list updates and restore selection if nothing is selected + this._register(sessionsControl.onDidUpdate(() => { + if (!sessionsControl.hasFocusOrSelection()) { + this.restoreLastSelectedSession(); + } + })); + + // When the active session changes, select it in the list + this._register(autorun(reader => { + const activeSession = this.sessionsManagementService.activeSession.read(reader); + if (activeSession) { + if (!sessionsControl.reveal(activeSession.resource)) { + sessionsControl.clearFocus(); + } + } else { + sessionsControl.clearFocus(); + } + })); + + // AI Customization toolbar (bottom, fixed height) + this._register(this.instantiationService.createInstance(AICustomizationShortcutsWidget, sessionsContainer, { + onDidToggleCollapse: () => { + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }, + })); + } + + private restoreLastSelectedSession(): void { + const activeSession = this.sessionsManagementService.activeSession.get(); + if (activeSession && this.sessionsControl) { + this.sessionsControl.reveal(activeSession.resource); + } + } + + private readonly registeredFilterTypeIds = new Set(); + + private registerSessionTypeFilters(sessionsControl: SessionsList): void { + const sessionTypes = this.sessionsManagementService.getAllSessionTypes(); + for (let i = 0; i < sessionTypes.length; i++) { + const type = sessionTypes[i]; + + // Skip if already registered (action IDs are global and can't be re-registered) + if (this.registeredFilterTypeIds.has(type.id)) { + continue; + } + this.registeredFilterTypeIds.add(type.id); + + const contextKey = new RawContextKey(`sessionsViewPane.filterType.${type.id}`, !sessionsControl.isSessionTypeExcluded(type.id)); + const contextKeyInstance = contextKey.bindTo(this.scopedContextKeyService); + this.filterContextKeys.set(contextKey.key, { key: contextKeyInstance, getDefault: () => true }); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: `sessionsViewPane.filterType.${type.id}`, + title: type.label, + toggled: ContextKeyExpr.equals(contextKey.key, true), + menu: [{ + id: SessionsViewFilterOptionsSubMenu, + group: '1_types', + order: i, + }] + }); + } + override run() { + const isExcluded = sessionsControl.isSessionTypeExcluded(type.id); + sessionsControl.setSessionTypeExcluded(type.id, !isExcluded); + contextKeyInstance.set(isExcluded); // was excluded, now included (toggle) + } + })); + } + } + + private registerStatusFilters(sessionsControl: SessionsList): void { + const statusFilters: { status: SessionStatus; label: string }[] = [ + { status: SessionStatus.Completed, label: localize('statusCompleted', "Completed") }, + { status: SessionStatus.InProgress, label: localize('statusInProgress', "In Progress") }, + { status: SessionStatus.NeedsInput, label: localize('statusNeedsInput', "Input Needed") }, + { status: SessionStatus.Error, label: localize('statusFailed', "Failed") }, + ]; + for (let i = 0; i < statusFilters.length; i++) { + const { status, label } = statusFilters[i]; + const contextKey = new RawContextKey(`sessionsViewPane.filterStatus.${status}`, !sessionsControl.isStatusExcluded(status)); + const contextKeyInstance = contextKey.bindTo(this.scopedContextKeyService); + this.filterContextKeys.set(contextKey.key, { key: contextKeyInstance, getDefault: () => true }); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: `sessionsViewPane.filterStatus.${status}`, + title: label, + toggled: ContextKeyExpr.equals(contextKey.key, true), + menu: [{ + id: SessionsViewFilterOptionsSubMenu, + group: '2_status', + order: i, + }] + }); + } + override run() { + const isExcluded = sessionsControl.isStatusExcluded(status); + sessionsControl.setStatusExcluded(status, !isExcluded); + contextKeyInstance.set(isExcluded); + } + })); + } + + // Archived toggle + const archivedContextKey = new RawContextKey('sessionsViewPane.filter.showArchived', !sessionsControl.isExcludeArchived()); + const archivedContextKeyInstance = archivedContextKey.bindTo(this.scopedContextKeyService); + this.filterContextKeys.set(archivedContextKey.key, { key: archivedContextKeyInstance, getDefault: () => false }); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.filterArchived', + title: localize('filterArchived', "Archived"), + toggled: ContextKeyExpr.equals(archivedContextKey.key, true), + menu: [{ + id: SessionsViewFilterOptionsSubMenu, + group: '3_props', + order: 0, + }] + }); + } + override run() { + const excluding = sessionsControl.isExcludeArchived(); + sessionsControl.setExcludeArchived(!excluding); + archivedContextKeyInstance.set(excluding); // was excluding → now showing + } + })); + + // Read toggle + const readContextKey = new RawContextKey('sessionsViewPane.filter.showRead', !sessionsControl.isExcludeRead()); + const readContextKeyInstance = readContextKey.bindTo(this.scopedContextKeyService); + this.filterContextKeys.set(readContextKey.key, { key: readContextKeyInstance, getDefault: () => true }); + + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.filterRead', + title: localize('filterRead', "Read"), + toggled: ContextKeyExpr.equals(readContextKey.key, true), + menu: [{ + id: SessionsViewFilterOptionsSubMenu, + group: '3_props', + order: 1, + }] + }); + } + override run() { + const excluding = sessionsControl.isExcludeRead(); + sessionsControl.setExcludeRead(!excluding); + readContextKeyInstance.set(excluding); + } + })); + + // Reset filter action + const filterContextKeys = this.filterContextKeys; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.resetFilters', + title: localize('resetFilters', "Reset"), + menu: [{ + id: SessionsViewFilterOptionsSubMenu, + group: '4_reset', + order: 0, + }] + }); + } + override run() { + sessionsControl.resetFilters(); + for (const { key, getDefault } of filterContextKeys.values()) { + key.set(getDefault()); + } + } + })); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + + if (!this.sessionsControl || !this.sessionsControlContainer) { + return; + } + + this.sessionsControl.layout(this.sessionsControlContainer.offsetHeight, width); + } + + override focus(): void { + super.focus(); + + this.sessionsControl?.focus(); + } + + refresh(): void { + this.sessionsControl?.refresh(); + } + + openFind(): void { + this.sessionsControl?.openFind(); + } + + setGrouping(grouping: SessionsGrouping): void { + if (this.currentGrouping === grouping) { + return; + } + + this.currentGrouping = grouping; + this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); + this.groupingContextKey?.set(this.currentGrouping); + this.sessionsControl?.resetSectionCollapseState(); + this.sessionsControl?.update(true); + } + + setSorting(sorting: SessionsSorting): void { + if (this.currentSorting === sorting) { + return; + } + + this.currentSorting = sorting; + this.storageService.store(SORTING_STORAGE_KEY, this.currentSorting, StorageScope.PROFILE, StorageTarget.USER); + this.sortingContextKey?.set(this.currentSorting); + this.sessionsControl?.update(); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts new file mode 100644 index 0000000000000..b956d4bd30c67 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -0,0 +1,509 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { EditorsVisibleContext, IsAuxiliaryWindowContext } from '../../../../../workbench/common/contextkeys.js'; +import { AgentSessionSection, IAgentSessionSection, isAgentSessionSection } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { AUX_WINDOW_GROUP } from '../../../../../workbench/services/editor/common/editorService.js'; +import { SessionsCategories } from '../../../../common/categories.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, IsSessionPinnedContext, IsSessionArchivedContext, IsSessionReadContext, SessionsGrouping, SessionsSorting } from './sessionsList.js'; +import { ISessionsManagementService, IsNewChatSessionContext } from '../sessionsManagementService.js'; +import { ISessionData, SessionStatus } from '../../common/sessionData.js'; +import { IsRepositoryGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; +import { SessionsViewId as NewChatViewId } from '../../../chat/browser/newChatViewPane.js'; +import { Menus } from '../../../../browser/menus.js'; +import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; + +// Constants + +const ACTION_ID_NEW_SESSION = 'workbench.action.chat.newChat'; +// Keybindings + +KeybindingsRegistry.registerKeybindingRule({ + id: ACTION_ID_NEW_SESSION, + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.KeyN, +}); + +const CLOSE_SESSION_COMMAND_ID = 'sessionsViewPane.closeSession'; +registerAction2(class CloseSessionAction extends Action2 { + constructor() { + super({ + id: CLOSE_SESSION_COMMAND_ID, + title: localize2('closeSession', "Close Session"), + f1: true, + precondition: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), + category: SessionsCategories.Sessions, + }); + } + override async run(accessor: ServicesAccessor) { + const sessionsService = accessor.get(ISessionsManagementService); + sessionsService.openNewSessionView(); + } +}); + +KeybindingsRegistry.registerKeybindingRule({ + id: CLOSE_SESSION_COMMAND_ID, + weight: KeybindingWeight.WorkbenchContrib + 1, + when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, +}); + +// View Title Menu + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: SessionsViewFilterSubMenu, + title: localize2('filterSessions', "Filter Sessions"), + group: 'navigation', + order: 3, + icon: Codicon.settings, + when: ContextKeyExpr.equals('view', SessionsViewId) +}); + +MenuRegistry.appendMenuItem(SessionsViewFilterSubMenu, { + submenu: SessionsViewFilterOptionsSubMenu, + title: localize2('filter', "Filter"), + group: '0_filter', + order: 0, +}); + +// Sort / Group Actions + +registerAction2(class SortByCreatedAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.sortByCreated', + title: localize2('sortByCreated', "Sort by Created"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, SessionsSorting.Created), + menu: [{ id: SessionsViewFilterSubMenu, group: '1_sort', order: 0 }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setSorting(SessionsSorting.Created); + } +}); + +registerAction2(class SortByUpdatedAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.sortByUpdated', + title: localize2('sortByUpdated', "Sort by Updated"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, SessionsSorting.Updated), + menu: [{ id: SessionsViewFilterSubMenu, group: '1_sort', order: 1 }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setSorting(SessionsSorting.Updated); + } +}); + +registerAction2(class GroupByProjectAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.groupByProject', + title: localize2('groupByProject', "Group by Project"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Repository), + menu: [{ id: SessionsViewFilterSubMenu, group: '2_group', order: 0 }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(SessionsGrouping.Repository); + } +}); + +registerAction2(class GroupByTimeAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.groupByTime', + title: localize2('groupByTime', "Group by Time"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Date), + menu: [{ id: SessionsViewFilterSubMenu, group: '2_group', order: 1 }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(SessionsGrouping.Date); + } +}); + +// Repository Group Capping + +registerAction2(class ShowRecentSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.showRecentSessions', + title: localize2('showRecentSessions', "Show Recent Sessions"), + category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_cap', + order: 0, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Repository), + }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.setRepositoryGroupCapped(true); + IsRepositoryGroupCappedContext.bindTo(accessor.get(IContextKeyService)).set(true); + } +}); + +registerAction2(class ShowAllSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.showAllSessions', + title: localize2('showAllSessions', "Show All Sessions"), + category: SessionsCategories.Sessions, + toggled: IsRepositoryGroupCappedContext.negate(), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_cap', + order: 1, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Repository), + }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.setRepositoryGroupCapped(false); + IsRepositoryGroupCappedContext.bindTo(accessor.get(IContextKeyService)).set(false); + } +}); + +// View Toolbar Actions + +registerAction2(class RefreshSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.refresh', + title: localize2('refresh', "Refresh Sessions"), + icon: Codicon.refresh, + f1: true, + category: SessionsCategories.Sessions, + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.sessionsControl?.refresh(); + } +}); + +registerAction2(class FindSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.find', + title: localize2('find', "Find Session"), + icon: Codicon.search, + category: SessionsCategories.Sessions, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 2, + when: ContextKeyExpr.equals('view', SessionsViewId), + }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.openFind(); + } +}); + +// Section Actions + +registerAction2(class NewSessionForRepositoryAction extends Action2 { + constructor() { + super({ + id: 'agentSessionSection.newSession', + title: localize2('newSessionForRepo', "New Session"), + icon: Codicon.newSession, + menu: [{ + id: MenuId.AgentSessionSectionToolbar, + group: 'navigation', + order: 0, + when: ChatContextKeys.agentSessionSection.isEqualTo(AgentSessionSection.Repository), + }] + }); + } + async run(accessor: ServicesAccessor, context?: IAgentSessionSection): Promise { + if (!context || !isAgentSessionSection(context) || context.sessions.length === 0) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + const viewsService = accessor.get(IViewsService); + sessionsManagementService.openNewSessionView(); + await viewsService.openView(NewChatViewId, true); + } +}); + +// Session Item Actions + +registerAction2(class PinSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.pinSession', + title: localize2('pinSession', "Pin"), + icon: Codicon.pin, + menu: [{ + id: SessionItemToolbarMenuId, + group: 'navigation', + order: 0, + when: ContextKeyExpr.equals(IsSessionPinnedContext.key, false), + }, { + id: SessionItemContextMenuId, + group: '0_pin', + order: 0, + when: ContextKeyExpr.equals(IsSessionPinnedContext.key, false), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionData): void { + if (!context) { + return; + } + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.pinSession(context); + } +}); + +registerAction2(class UnpinSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.unpinSession', + title: localize2('unpinSession', "Unpin"), + icon: Codicon.pinned, + menu: [{ + id: SessionItemToolbarMenuId, + group: 'navigation', + order: 0, + when: ContextKeyExpr.equals(IsSessionPinnedContext.key, true), + }, { + id: SessionItemContextMenuId, + group: '0_pin', + order: 0, + when: ContextKeyExpr.equals(IsSessionPinnedContext.key, true), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionData): void { + if (!context) { + return; + } + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.unpinSession(context); + } +}); + +registerAction2(class ArchiveSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.archiveSession', + title: localize2('archiveSession', "Archive"), + icon: Codicon.archive, + menu: [{ + id: SessionItemToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + }, { + id: SessionItemContextMenuId, + group: '1_edit', + order: 2, + when: ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + if (!context) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + await sessionsManagementService.archiveSession(context); + } +}); + +registerAction2(class UnarchiveSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.unarchiveSession', + title: localize2('unarchiveSession', "Unarchive"), + icon: Codicon.unarchive, + menu: [{ + id: SessionItemToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.equals(IsSessionArchivedContext.key, true), + }, { + id: SessionItemContextMenuId, + group: '1_edit', + order: 2, + when: ContextKeyExpr.equals(IsSessionArchivedContext.key, true), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + if (!context) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + await sessionsManagementService.unarchiveSession(context); + } +}); + +registerAction2(class MarkSessionReadAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.markRead', + title: localize2('markRead', "Mark as Read"), + menu: [{ + id: SessionItemContextMenuId, + group: '0_read', + order: 0, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionReadContext.key, false), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionData): void { + if (!context) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + sessionsManagementService.setRead(context, true); + } +}); + +registerAction2(class MarkSessionUnreadAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.markUnread', + title: localize2('markUnread', "Mark as Unread"), + menu: [{ + id: SessionItemContextMenuId, + group: '0_read', + order: 0, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionReadContext.key, true), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISessionData): void { + if (!context) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + sessionsManagementService.setRead(context, false); + } +}); + +registerAction2(class OpenSessionInNewWindowAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.openInNewWindow', + title: localize2('openInNewWindow', "Open in New Window"), + menu: [{ + id: SessionItemContextMenuId, + group: 'navigation', + order: 0, + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionData): Promise { + if (!context) { + return; + } + const chatWidgetService = accessor.get(IChatWidgetService); + await chatWidgetService.openSession(context.resource, AUX_WINDOW_GROUP, { + auxiliary: { compact: true, bounds: { width: 800, height: 640 } }, + pinned: true + }); + } +}); + +registerAction2(class MarkAllSessionsReadAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.markAllRead', + title: localize2('markAllRead', "Mark All as Read"), + menu: [{ + id: SessionItemContextMenuId, + group: '0_read', + order: 1, + }] + }); + } + run(accessor: ServicesAccessor): void { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const sessions = sessionsManagementService.getSessions(); + for (const session of sessions) { + if (!session.isArchived.get() && !session.isRead.get()) { + sessionsManagementService.setRead(session, true); + } + } + } +}); + +registerAction2(class MarkSessionAsDoneAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.markAsDone', + title: localize2('markAsDone', "Mark as Done"), + icon: Codicon.check, + menu: [{ + id: Menus.CommandCenter, + order: 102, + when: ContextKeyExpr.and( + IsAuxiliaryWindowContext.negate(), + SessionsWelcomeVisibleContext.negate(), + IsNewChatSessionContext.negate() + ) + }] + }); + } + + async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + + const activeSession = sessionsManagementService.activeSession.get(); + if (!activeSession || activeSession.status.get() === SessionStatus.Untitled) { + return; + } + sessionsManagementService.archiveSession(activeSession); + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewPane.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewPane.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/common/sessionData.ts b/src/vs/sessions/contrib/sessions/common/sessionData.ts new file mode 100644 index 0000000000000..12af777207902 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/common/sessionData.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IChatSessionFileChange } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; + +export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; + +/** + * Status of an agent session as reported by the sessions provider. + */ +export const enum SessionStatus { + /** Session has not been sent yet (new/untitled). */ + Untitled = 0, + /** Agent is actively working. */ + InProgress = 1, + /** Agent is waiting for user input. */ + NeedsInput = 2, + /** Session has completed successfully. */ + Completed = 3, + /** Session encountered an error. */ + Error = 4, +} + +/** + * A repository within a session workspace. + */ +export interface ISessionRepository { + /** The source repository URI. */ + readonly uri: URI; + /** The working directory URI (e.g., a git worktree or checkout path). */ + readonly workingDirectory: URI | undefined; + /** Provider-chosen display detail (e.g., branch name, host name). */ + readonly detail: string | undefined; + /** Whether the base branch is protected (drives PR vs merge workflow). */ + readonly baseBranchProtected: boolean | undefined; +} + +/** + * Workspace information for a session, encapsulating one or more repositories. + */ +export interface ISessionWorkspace { + /** Display label for the workspace (e.g., "my-app", "org/repo", "host:/path"). */ + readonly label: string; + /** Icon for the workspace. */ + readonly icon: ThemeIcon; + /** Repositories in this workspace. */ + readonly repositories: ISessionRepository[]; +} + +/** + * The common session interface exposed by sessions providers. + * Self-contained facade — components should not reach back to underlying + * services to resolve additional data. + */ +export interface ISessionData { + /** Globally unique session ID (`providerId:localId`). */ + readonly sessionId: string; + /** Resource URI identifying this session. */ + readonly resource: URI; + /** ID of the provider that owns this session. */ + readonly providerId: string; + /** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */ + readonly sessionType: string; + /** Icon for this session. */ + readonly icon: ThemeIcon; + /** When the session was created. */ + readonly createdAt: Date; + /** Workspace this session operates on. */ + readonly workspace: IObservable; + + // Reactive properties + + /** Session display title (changes when auto-titled or renamed). */ + readonly title: IObservable; + /** When the session was last updated. */ + readonly updatedAt: IObservable; + /** Current session status. */ + readonly status: IObservable; + /** File changes produced by the session. */ + readonly changes: IObservable; + /** Currently selected model identifier. */ + readonly modelId: IObservable; + /** Currently selected mode identifier and kind. */ + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + /** Whether the session is still initializing (e.g., resolving git repository). */ + readonly loading: IObservable; + /** Whether the session is archived. */ + readonly isArchived: IObservable; + /** Whether the session has been read. */ + readonly isRead: IObservable; + /** Status description shown while the session is active (e.g., current agent action). */ + readonly description: IObservable; + /** Timestamp of when the last agent turn ended, if any. */ + readonly lastTurnEnd: IObservable; + /** URI of the pull request associated with this session, if any. */ + readonly pullRequestUri: IObservable; +} diff --git a/src/vs/sessions/contrib/sessions/common/sessionsProvider.ts b/src/vs/sessions/contrib/sessions/common/sessionsProvider.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/common/sessionsProvider.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/common/sessionsProvidersService.ts b/src/vs/sessions/contrib/sessions/common/sessionsProvidersService.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/common/sessionsProvidersService.ts @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts new file mode 100644 index 0000000000000..40f6cbce35656 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuActionOptions, IMenuService, isIMenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPromptsService, PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; +import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; +import { ISessionsManagementService } from '../../browser/sessionsManagementService.js'; +import { ISessionData } from '../../common/sessionData.js'; +import { Menus } from '../../../../browser/menus.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// ============================================================================ +// One-time menu item registration (module-level). +// MenuRegistry.appendMenuItem does not throw on duplicates, unlike registerAction2 +// which registers global commands and throws on the second call. +// ============================================================================ + +const menuRegistrations = new DisposableStore(); +for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + menuRegistrations.add(MenuRegistry.appendMenuItem(Menus.SidebarCustomizations, { + command: { id: config.id, title: config.label }, + group: 'navigation', + order: index + 1, + })); +} + +// ============================================================================ +// FixtureMenuService — reads from MenuRegistry without context-key filtering +// (MockContextKeyService.contextMatchesRules always returns false, which hides +// every item when using the real MenuService.) +// ============================================================================ + +class FixtureMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + + createMenu(id: MenuId): IMenu { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => { + const items = MenuRegistry.getMenuItems(id).filter(isIMenuItem); + items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const actions = items.map(item => { + const title = typeof item.command.title === 'string' ? item.command.title : item.command.title.value; + return toAction({ id: item.command.id, label: title, run: () => { } }); + }); + return actions.length ? [['navigation', actions as unknown as (MenuItemAction | SubmenuItemAction)[]]] : []; + }, + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +// ============================================================================ +// Minimal IActionViewItemService that supports register/lookUp +// ============================================================================ + +class FixtureActionViewItemService implements IActionViewItemService { + declare _serviceBrand: undefined; + + private readonly _providers = new Map(); + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory): { dispose(): void } { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + this._providers.set(key, provider); + return { dispose: () => { this._providers.delete(key); } }; + } + + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + return this._providers.get(key); + } +} + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], +}; + +function createMockPromptsService(): IPromptsService { + return createMockPromptsServiceWithCounts(); +} + +interface ICustomizationCounts { + readonly agents?: number; + readonly skills?: number; + readonly instructions?: number; + readonly prompts?: number; + readonly hooks?: number; +} + +function createMockPromptsServiceWithCounts(counts?: ICustomizationCounts): IPromptsService { + const fakeUri = (prefix: string, i: number) => URI.parse(`file:///mock/${prefix}-${i}.md`); + const fakeItem = (prefix: string, i: number) => ({ uri: fakeUri(prefix, i), storage: PromptsStorage.local }); + + const agents = Array.from({ length: counts?.agents ?? 0 }, (_, i) => ({ + uri: fakeUri('agent', i), + source: { storage: PromptsStorage.local }, + })); + const skills = Array.from({ length: counts?.skills ?? 0 }, (_, i) => fakeItem('skill', i)); + const prompts = Array.from({ length: counts?.prompts ?? 0 }, (_, i) => ({ + promptPath: { uri: fakeUri('prompt', i), storage: PromptsStorage.local, type: PromptsType.prompt }, + })); + const instructions = Array.from({ length: counts?.instructions ?? 0 }, (_, i) => fakeItem('instructions', i)); + const hooks = Array.from({ length: counts?.hooks ?? 0 }, (_, i) => fakeItem('hook', i)); + + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override async getCustomAgents() { return agents as never[]; } + override async findAgentSkills() { return skills as never[]; } + override async getPromptSlashCommands() { return prompts as never[]; } + override async listPromptFiles(type: PromptsType) { + return (type === PromptsType.hook ? hooks : instructions) as never[]; + } + override async listAgentInstructions() { return [] as never[]; } + }(); +} + +function createMockMcpService(serverCount: number = 0): IMcpService { + const MockServer = mock(); + const servers = observableValue('mockMcpServers', Array.from({ length: serverCount }, () => new MockServer())); + return new class extends mock() { + override readonly servers = servers; + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', undefined); + return new class extends mock() { + override readonly activeProjectRoot = activeProjectRoot; + override getActiveProjectRoot() { return undefined; } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: number; collapsed?: boolean; counts?: ICustomizationCounts }): void { + ctx.container.style.width = '300px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const actionViewItemService = new FixtureActionViewItemService(); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Register overrides BEFORE registerWorkbenchServices so they take priority + reg.defineInstance(IMenuService, new FixtureMenuService()); + reg.defineInstance(IActionViewItemService, actionViewItemService); + registerWorkbenchServices(reg); + // Services needed by AICustomizationShortcutsWidget + reg.defineInstance(IPromptsService, options?.counts ? createMockPromptsServiceWithCounts(options.counts) : createMockPromptsService()); + reg.defineInstance(IMcpService, createMockMcpService(options?.mcpServerCount ?? 0)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('mockPlugins', []); + }()); + // Additional services needed by CustomizationLinkViewItem + reg.defineInstance(ILanguageModelsService, new class extends mock() { + override readonly onDidChangeLanguageModels = Event.None; + }()); + reg.defineInstance(ISessionsManagementService, new class extends mock() { + override readonly activeSession = observableValue('activeSession', undefined); + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + }, + }); + + // Register view item factories from the real CustomizationLinkViewItem (per-render, instance-scoped) + for (const config of CUSTOMIZATION_ITEMS) { + ctx.disposableStore.add(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + })); + } + + // Override storage to set initial collapsed state + if (options?.collapsed) { + const storageService = instantiationService.get(IStorageService); + instantiationService.set(IStorageService, new class extends mock() { + override getBoolean(key: string, scope: StorageScope, fallbackValue?: boolean) { + if (key === 'agentSessions.customizationsCollapsed') { + return true; + } + return storageService.getBoolean(key, scope, fallbackValue!); + } + override store() { } + }()); + } + + // Create the widget (uses FixtureMenuService → reads MenuRegistry items registered above) + ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationShortcutsWidget, ctx.container, undefined) + ); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + Expanded: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx), + }), + + Collapsed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { collapsed: true }), + }), + + WithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3 }), + }), + + CollapsedWithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3, collapsed: true }), + }), + + WithCounts: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { + mcpServerCount: 2, + counts: { agents: 2, skills: 30, instructions: 16, prompts: 17, hooks: 4 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts new file mode 100644 index 0000000000000..02d70905b805c --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -0,0 +1,796 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IResolvedAgentFile, AgentFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; +import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount } from '../../browser/customizationCounts.js'; +import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; + +function localFile(path: string): ILocalPromptPath { + return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions }; +} + +function userFile(path: string): IUserPromptPath { + return { uri: URI.file(path), storage: PromptsStorage.user, type: PromptsType.instructions }; +} + +function extensionFile(path: string): IExtensionPromptPath { + return { + uri: URI.file(path), + storage: PromptsStorage.extension, + type: PromptsType.instructions, + extension: undefined!, + source: undefined!, + }; +} + +function agentInstructionFile(path: string): IResolvedAgentFile { + return { uri: URI.file(path), realPath: undefined, type: AgentFileType.agentsMd }; +} + +function makeWorkspaceFolder(path: string, name?: string): IWorkspaceFolder { + const uri = URI.file(path); + return { + uri, + name: name ?? path.split('/').pop()!, + index: 0, + toResource: (rel: string) => URI.joinPath(uri, rel), + }; +} + +function createMockPromptsService(opts: { + localFiles?: IPromptPath[]; + userFiles?: IPromptPath[]; + extensionFiles?: IPromptPath[]; + allFiles?: IPromptPath[]; + agentInstructions?: IResolvedAgentFile[]; + agents?: { name: string; uri: URI; storage: PromptsStorage }[]; + skills?: { name: string; uri: URI; storage: PromptsStorage }[]; + commands?: { name: string; uri: URI; storage: PromptsStorage; type: PromptsType }[]; +} = {}): IPromptsService { + return { + listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => { + if (storage === PromptsStorage.local) { return opts.localFiles ?? []; } + if (storage === PromptsStorage.user) { return opts.userFiles ?? []; } + if (storage === PromptsStorage.extension) { return opts.extensionFiles ?? []; } + return []; + }, + listPromptFiles: async () => opts.allFiles ?? [...(opts.localFiles ?? []), ...(opts.userFiles ?? []), ...(opts.extensionFiles ?? [])], + listAgentInstructions: async () => opts.agentInstructions ?? [], + getCustomAgents: async () => (opts.agents ?? []).map(a => ({ + name: a.name, + uri: a.uri, + source: { storage: a.storage }, + })), + findAgentSkills: async () => (opts.skills ?? []).map(s => ({ + name: s.name, + uri: s.uri, + storage: s.storage, + })), + getPromptSlashCommands: async () => (opts.commands ?? []).map(c => ({ + name: c.name, + promptPath: { uri: c.uri, storage: c.storage, type: c.type }, + })), + getSourceFolders: async () => [], + getResolvedSourceFolders: async () => [], + onDidChangeCustomAgents: Event.None, + onDidChangeSlashCommands: Event.None, + } as unknown as IPromptsService; +} + +function createMockWorkspaceService(opts: { + activeRoot?: URI; + filter?: IStorageSourceFilter; +} = {}): IAICustomizationWorkspaceService { + const defaultFilter: IStorageSourceFilter = opts.filter ?? { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], + }; + return { + _serviceBrand: undefined, + activeProjectRoot: observableValue('test', opts.activeRoot), + getActiveProjectRoot: () => opts.activeRoot, + managementSections: [], + getStorageSourceFilter: () => defaultFilter, + preferManualCreation: false, + commitFiles: async () => { }, + generateCustomization: async () => { }, + } as unknown as IAICustomizationWorkspaceService; +} + +function createMockWorkspaceContextService(folders: IWorkspaceFolder[]): IWorkspaceContextService { + return { + getWorkspace: () => ({ folders } as IWorkspace), + getWorkbenchState: () => WorkbenchState.FOLDER, + getWorkspaceFolder: () => folders[0], + onDidChangeWorkspaceFolders: Event.None, + onDidChangeWorkbenchState: Event.None, + onDidChangeWorkspaceName: Event.None, + isInsideWorkspace: () => true, + } as unknown as IWorkspaceContextService; +} + +suite('customizationCounts', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const workspaceRoot = URI.file('/workspace'); + const workspaceFolder = makeWorkspaceFolder('/workspace'); + + suite('getSourceCountsTotal', () => { + test('sums only visible sources', () => { + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 8); + }); + + test('returns 0 for empty sources', () => { + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; + const filter: IStorageSourceFilter = { sources: [] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 0); + }); + + test('sums all sources', () => { + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 10); + }); + + test('handles single source', () => { + const counts = { workspace: 7, user: 0, extension: 0, builtin: 0 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.local] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 7); + }); + + test('ignores plugin storage in totals (not in ISourceCounts)', () => { + const counts = { workspace: 1, user: 1, extension: 1, builtin: 0 }; + const filter: IStorageSourceFilter = { sources: [PromptsStorage.plugin] }; + assert.strictEqual(getSourceCountsTotal(counts, filter), 0); + }); + }); + + suite('getSourceCounts - instructions', () => { + test('includes agent instruction files in workspace count', async () => { + const promptsService = createMockPromptsService({ + localFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + ], + userFiles: [], + extensionFiles: [], + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + ], + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/.github/copilot-instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + // 1 .instructions.md + 2 agent instruction files = 3 workspace + assert.strictEqual(counts.workspace, 3); + assert.strictEqual(counts.user, 0); + }); + + test('classifies agent instructions outside workspace as user', async () => { + const promptsService = createMockPromptsService({ + localFiles: [], + userFiles: [], + extensionFiles: [], + allFiles: [], + agentInstructions: [ + agentInstructionFile('/home/user/.claude/CLAUDE.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 0); + assert.strictEqual(counts.user, 1); + }); + + test('agent instructions under active root classified as workspace', async () => { + // Active root might not be in getWorkspace().folders (e.g. sessions worktree), + // but should still count as workspace + const activeRoot = URI.file('/session/worktree'); + const promptsService = createMockPromptsService({ + allFiles: [], + agentInstructions: [ + agentInstructionFile('/session/worktree/AGENTS.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot }); + // No workspace folders match — but active root does + const contextService = createMockWorkspaceContextService([]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + + test('no agent instructions returns only prompt file counts', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + localFile('/workspace/.github/instructions/b.instructions.md'), + ], + agentInstructions: [], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 2); + }); + + test('mixed agent instructions across workspace and user', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/rules.instructions.md'), + ], + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/CLAUDE.md'), + agentInstructionFile('/home/user/.claude/CLAUDE.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + // 1 .instructions.md + 2 workspace agent files = 3 + assert.strictEqual(counts.workspace, 3); + // 1 user-level CLAUDE.md + assert.strictEqual(counts.user, 1); + }); + }); + + suite('getSourceCounts - agents', () => { + test('uses getCustomAgents instead of listPromptFilesForStorage', async () => { + const promptsService = createMockPromptsService({ + // listPromptFilesForStorage would return these — but agents should use getCustomAgents + localFiles: [localFile('/workspace/.github/agents/a.agent.md')], + agents: [ + { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.agent, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + // Should use getCustomAgents (2), not listPromptFilesForStorage (1) + assert.strictEqual(counts.workspace, 2); + }); + + test('counts agents across storage types', async () => { + const promptsService = createMockPromptsService({ + agents: [ + { name: 'local-agent', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'user-agent', uri: URI.file('/home/.claude/agents/b.agent.md'), storage: PromptsStorage.user }, + { name: 'ext-agent', uri: URI.file('/ext/agents/c.agent.md'), storage: PromptsStorage.extension }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.agent, + { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }, + contextService, + workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1, builtin: 0 }); + }); + + test('empty agents returns all zeros', async () => { + const promptsService = createMockPromptsService({ agents: [] }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.agent, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); + }); + }); + + suite('getSourceCounts - skills', () => { + test('uses findAgentSkills', async () => { + const promptsService = createMockPromptsService({ + skills: [ + { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, + { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.skill, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 1); + }); + + test('empty skills returns zeros', async () => { + const promptsService = createMockPromptsService({ skills: [] }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.skill, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); + }); + + test('skills filtered by storage source filter', async () => { + const promptsService = createMockPromptsService({ + skills: [ + { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, + { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + // Only local sources visible + const counts = await getSourceCounts( + promptsService, PromptsType.skill, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + }); + + suite('getSourceCounts - prompts', () => { + test('uses getPromptSlashCommands and filters out skills', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 'my-prompt', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'my-skill', uri: URI.file('/workspace/.github/skills/b/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.prompt, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + // Should exclude the skill command + assert.strictEqual(counts.workspace, 1); + }); + + test('counts prompts across storage types', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 'wp', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'up', uri: URI.file('/home/user/prompts/b.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0, builtin: 0 }); + }); + + test('all skills are excluded from prompt counts', async () => { + const promptsService = createMockPromptsService({ + commands: [ + { name: 's1', uri: URI.file('/w/s1/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + { name: 's2', uri: URI.file('/w/s2/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); + }); + }); + + suite('getSourceCounts - hooks', () => { + test('uses listPromptFiles for hooks', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/hooks/pre-commit.json'), + localFile('/workspace/.claude/settings.json'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.hook, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 2); + }); + + test('hooks with only local source excludes user hooks', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/hooks/pre-commit.json'), + userFile('/home/user/.claude/settings.json'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.hook, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.user, 0); + }); + }); + + suite('getSourceCounts - filter', () => { + test('applies includedUserFileRoots filter', async () => { + const copilotRoot = URI.file('/home/user/.copilot'); + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + userFile('/home/user/.copilot/instructions/b.instructions.md'), + userFile('/home/user/.vscode/instructions/c.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [copilotRoot], + }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + // Only the copilot file passes, not the vscode profile file + assert.strictEqual(counts.user, 1); + }); + + test('excludes storage types not in sources', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + localFile('/workspace/.github/instructions/a.instructions.md'), + extensionFile('/ext/instructions/b.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, + PromptsType.instructions, + { sources: [PromptsStorage.local] }, + contextService, + workspaceService, + ); + + assert.strictEqual(counts.workspace, 1); + assert.strictEqual(counts.extension, 0); + }); + + test('includedUserFileRoots with multiple roots', async () => { + const copilotRoot = URI.file('/home/user/.copilot'); + const claudeRoot = URI.file('/home/user/.claude'); + const promptsService = createMockPromptsService({ + allFiles: [ + userFile('/home/user/.copilot/instructions/a.instructions.md'), + userFile('/home/user/.claude/rules/b.md'), + userFile('/home/user/.vscode/instructions/c.instructions.md'), + userFile('/home/user/.agents/instructions/d.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { + sources: [PromptsStorage.local, PromptsStorage.user], + includedUserFileRoots: [copilotRoot, claudeRoot], + }, + contextService, workspaceService, + ); + + // copilot + claude pass, vscode + agents don't + assert.strictEqual(counts.user, 2); + }); + + test('undefined includedUserFileRoots shows all user files', async () => { + const promptsService = createMockPromptsService({ + allFiles: [ + userFile('/home/user/.copilot/instructions/a.instructions.md'), + userFile('/home/user/.vscode/instructions/b.instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.user] }, + contextService, workspaceService, + ); + + assert.strictEqual(counts.user, 2); + }); + }); + + suite('getCustomizationTotalCount', () => { + test('sums all sections', async () => { + const promptsService = createMockPromptsService({ + agents: [ + { name: 'a', uri: URI.file('/w/a.agent.md'), storage: PromptsStorage.local }, + ], + skills: [ + { name: 's', uri: URI.file('/w/s/SKILL.md'), storage: PromptsStorage.local }, + ], + commands: [ + { name: 'p', uri: URI.file('/w/p.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + ], + }); + const mcpService = { + servers: observableValue('test', [{ id: 'srv1' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + activeRoot: URI.file('/w'), + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + // 1 agent + 1 skill + 0 instructions + 1 prompt + 0 hooks + 1 mcp = 4 + assert.strictEqual(total, 4); + }); + + test('empty workspace returns only mcp count', async () => { + const promptsService = createMockPromptsService({}); + const mcpService = { + servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + assert.strictEqual(total, 2); // just 2 mcp servers + }); + + test('includes instructions with agent files in count', async () => { + const instructionFiles = [ + localFile('/w/.github/instructions/a.instructions.md'), + ]; + const promptsService = createMockPromptsService({ + allFiles: instructionFiles, + agentInstructions: [ + agentInstructionFile('/w/AGENTS.md'), + ], + }); + // Override listPromptFiles to only return files for instructions type + promptsService.listPromptFiles = async (type: PromptsType) => { + return type === PromptsType.instructions ? instructionFiles : []; + }; + const mcpService = { + servers: observableValue('test', []), + } as unknown as IMcpService; + const workspaceService = createMockWorkspaceService({ + activeRoot: URI.file('/w'), + filter: { sources: [PromptsStorage.local] }, + }); + const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); + + const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); + + // 0 agents + 0 skills + 2 instructions (1 file + 1 AGENTS.md) + 0 prompts + 0 hooks + 0 mcp = 2 + assert.strictEqual(total, 2); + }); + }); + + suite('data source consistency', () => { + // These tests verify that getSourceCounts uses the same data sources + // as the list widget's loadItems() — the root cause of the count mismatch bug. + + test('instructions count matches widget: listPromptFiles + listAgentInstructions', async () => { + // Scenario: 13 .instructions.md files + 2 agent instruction files = 15 total + // The old bug: sidebar showed 13 (only listPromptFilesForStorage), + // editor showed 15 (listPromptFiles + listAgentInstructions) + const instructionFiles = Array.from({ length: 13 }, (_, i) => + localFile(`/workspace/.github/instructions/rule-${i}.instructions.md`) + ); + const promptsService = createMockPromptsService({ + localFiles: instructionFiles, + allFiles: instructionFiles, + agentInstructions: [ + agentInstructionFile('/workspace/AGENTS.md'), + agentInstructionFile('/workspace/.github/copilot-instructions.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + // Must be 15, not 13 + assert.strictEqual(counts.workspace, 15); + }); + + test('agents count uses getCustomAgents not listPromptFilesForStorage', async () => { + // getCustomAgents parses frontmatter and may exclude invalid files + const promptsService = createMockPromptsService({ + // Raw file count would be 3 + localFiles: [ + localFile('/workspace/.github/agents/a.agent.md'), + localFile('/workspace/.github/agents/b.agent.md'), + localFile('/workspace/.github/agents/README.md'), // would be excluded by getCustomAgents + ], + // But parsed custom agents is only 2 + agents: [ + { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, + { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.agent, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + // Must use getCustomAgents count (2), not raw file count (3) + assert.strictEqual(counts.workspace, 2); + }); + + test('prompts count excludes skills to match widget', async () => { + // The widget's loadItems filters out skill-type commands. + // Count must do the same. + const promptsService = createMockPromptsService({ + localFiles: [ + localFile('/workspace/.github/prompts/a.prompt.md'), + localFile('/workspace/.github/prompts/b.prompt.md'), + ], + commands: [ + { name: 'prompt-a', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'prompt-b', uri: URI.file('/workspace/.github/prompts/b.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, + { name: 'skill-x', uri: URI.file('/workspace/.github/skills/x/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); + const contextService = createMockWorkspaceContextService([workspaceFolder]); + + const counts = await getSourceCounts( + promptsService, PromptsType.prompt, + { sources: [PromptsStorage.local] }, + contextService, workspaceService, + ); + + // Must be 2 (prompts only), not 3 (including skill) + assert.strictEqual(counts.workspace, 2); + }); + + test('no active root: agent instructions classified as user', async () => { + const promptsService = createMockPromptsService({ + allFiles: [], + agentInstructions: [ + agentInstructionFile('/somewhere/AGENTS.md'), + ], + }); + const workspaceService = createMockWorkspaceService({ activeRoot: undefined }); + const contextService = createMockWorkspaceContextService([]); + + const counts = await getSourceCounts( + promptsService, PromptsType.instructions, + { sources: [PromptsStorage.local, PromptsStorage.user] }, + contextService, workspaceService, + ); + + // No workspace context → classified as user + assert.strictEqual(counts.workspace, 0); + assert.strictEqual(counts.user, 1); + }); + }); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index ff97b6ae70c44..d248d4c700535 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -8,128 +8,285 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionData } from '../../sessions/common/sessionData.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); /** - * Returns the cwd URI for the given session: worktree for non-cloud agent - * sessions, repository otherwise, or `undefined` when neither is available. + * Returns the cwd URI for the given session: worktree or repository path for + * background sessions only. Returns `undefined` for non-background sessions + * (Cloud, Local, etc.) which have no local worktree, or when no path is available. */ -function getSessionCwd(session: IActiveSessionItem | undefined): URI | undefined { - if (isAgentSession(session) && session.providerType !== AgentSessionProviders.Cloud) { - return session.worktree ?? session.repository; +function getSessionCwd(session: ISessionData | undefined): URI | undefined { + if (session?.sessionType !== AgentSessionProviders.Background) { + return undefined; } - return session?.repository; + const repo = session.workspace.get()?.repositories[0]; + return repo?.workingDirectory ?? repo?.uri; } /** * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). - * - A path→instanceId mapping tracks which terminal belongs to which worktree. + * - Terminals are shown/hidden based on their initial cwd matching the active path. * - All terminals for a worktree are closed when the session is archived. */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsTerminal'; - /** Maps worktree/repository fsPath (lower-cased) to the terminal instance id. */ - private readonly _pathToInstanceId = new Map(); - private _lastTargetFsPath: string | undefined; + private _activeKey: string | undefined; constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ITerminalService private readonly _terminalService: ITerminalService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @ILogService private readonly _logService: ILogService, + @IPathService private readonly _pathService: IPathService, + @IViewsService viewsService: IViewsService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); - // React to active session worktree/repository path changes + // Track whether the terminal view is visible so the titlebar toggle + // button shows the correct checked state. + const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService); + terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID)); + this._register(viewsService.onDidChangeViewVisibility(e => { + if (e.id === TERMINAL_VIEW_ID) { + terminalViewVisible.set(e.visible); + } + })); + + // React to active session changes — use worktree/repo for background sessions, home dir otherwise this._register(autorun(reader => { const session = this._sessionsManagementService.activeSession.read(reader); - const targetPath = getSessionCwd(session); - this._onActivePathChanged(targetPath); + this._onActiveSessionChanged(session); })); - // When a session is archived, close all terminals for its worktree - this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { - if (session.isArchived()) { - const worktreePath = session.metadata?.worktreePath as string | undefined; - if (worktreePath) { - this._closeTerminalsForPath(URI.file(worktreePath).fsPath); - } + // Hide restored terminals from a previous window session that don't + // belong to the current active session. These arrive asynchronously + // during reconnection and would otherwise flash in the foreground. + this._register(this._terminalService.onDidCreateInstance(instance => { + if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) { + instance.getInitialCwd().then(cwd => { + if (cwd.toLowerCase() !== this._activeKey) { + const availableInstance = this._getAvailableTerminal(instance, `hide restored terminal for ${cwd}`); + if (!availableInstance) { + return; + } + this._terminalService.moveToBackground(availableInstance); + this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`); + } + }); } })); - // Clean up mapping when terminals are disposed - this._register(this._terminalService.onDidDisposeInstance(instance => { - for (const [path, id] of this._pathToInstanceId) { - if (id === instance.instanceId) { - this._pathToInstanceId.delete(path); - break; + // When a session is archived or removed, close all terminals for its worktree + this._register(this._sessionsManagementService.onDidChangeSessions(e => { + for (const session of [...e.removed, ...e.changed.filter(s => s.isArchived.get())]) { + const worktreeUri = session.workspace.get()?.repositories[0]?.workingDirectory; + if (worktreeUri) { + this._closeTerminalsForPath(worktreeUri.fsPath); } } })); } /** - * Ensures a terminal exists for the given cwd, reusing an existing one - * from the mapping or creating a new one. Sets it as active and optionally - * focuses it. + * Ensures a terminal exists for the given cwd by scanning all terminal + * instances for a matching initial cwd. If none is found, creates a new + * one. Sets it as active and optionally focuses it. */ - async ensureTerminal(cwd: URI, focus: boolean): Promise { + async ensureTerminal(cwd: URI, focus: boolean): Promise { const key = cwd.fsPath.toLowerCase(); - const existingId = this._pathToInstanceId.get(key); - const existing = existingId !== undefined ? this._terminalService.getInstanceFromId(existingId) : undefined; + let existing = await this._findTerminalsForKey(key); - if (existing) { - this._terminalService.setActiveInstance(existing); - } else { - const instance = await this._terminalService.createTerminal({ config: { cwd } }); - this._pathToInstanceId.set(key, instance.instanceId); - this._terminalService.setActiveInstance(instance); - this._logService.trace(`[SessionsTerminal] Created terminal ${instance.instanceId} for ${cwd.fsPath}`); + if (existing.length === 0) { + try { + const createdInstance = this._getAvailableTerminal(await this._terminalService.createTerminal({ config: { cwd } }), `activate created terminal for ${cwd.fsPath}`); + if (!createdInstance) { + return []; + } + existing = [createdInstance]; + this._terminalService.setActiveInstance(createdInstance); + this._logService.trace(`[SessionsTerminal] Created terminal ${createdInstance.instanceId} for ${cwd.fsPath}`); + } catch (e) { + this._logService.trace(`[SessionsTerminal] Cannot create terminal for ${cwd.fsPath}: ${e}`); + return []; + } } if (focus) { await this._terminalService.focusActiveInstance(); } + + return existing; } - private async _onActivePathChanged(targetPath: URI | undefined): Promise { - if (!targetPath) { + private async _onActiveSessionChanged(session: ISessionData | undefined): Promise { + if (!session) { return; } - const targetFsPath = targetPath.fsPath; - if (this._lastTargetFsPath?.toLowerCase() === targetFsPath.toLowerCase()) { + const sessionCwd = getSessionCwd(session); + + const targetPath = sessionCwd ?? await this._pathService.userHome(); + const targetKey = targetPath.fsPath.toLowerCase(); + if (this._activeKey === targetKey) { return; } - this._lastTargetFsPath = targetFsPath; + this._activeKey = targetKey; + + const instances = await this.ensureTerminal(targetPath, false); + + // If the active key changed while we were awaiting, a newer call has + // taken over — skip the visibility update to avoid flicker. + if (this._activeKey !== targetKey) { + return; + } + await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId)); + } + + /** + * Finds the first terminal instance whose initial cwd (lower-cased) matches + * the given key. + */ + private async _findTerminalsForKey(key: string): Promise { + const result: ITerminalInstance[] = []; + for (const instance of this._terminalService.instances) { + try { + const cwd = await instance.getInitialCwd(); + if (cwd.toLowerCase() === key) { + result.push(instance); + } + } catch { + // ignore terminals whose cwd cannot be resolved + } + } + return result; + } - await this.ensureTerminal(targetPath, false); + private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined { + const currentInstance = this._terminalService.getInstanceFromId(instance.instanceId); + if (!currentInstance || currentInstance.isDisposed) { + this._logService.trace(`[SessionsTerminal] Cannot ${action}; terminal ${instance.instanceId} is no longer available`); + return undefined; + } + return currentInstance; } - private _closeTerminalsForPath(fsPath: string): void { + /** + * Shows background terminals whose initial cwd matches the active key and + * hides foreground terminals whose initial cwd does not match. + */ + private async _updateTerminalVisibility(activeKey: string, forceForegroundTerminalIds: number[]): Promise { + const toShow: ITerminalInstance[] = []; + const toHide: ITerminalInstance[] = []; + + for (const instance of [...this._terminalService.instances]) { + let cwd: string | undefined; + try { + cwd = (await instance.getInitialCwd()).toLowerCase(); + } catch { + continue; + } + const currentInstance = this._getAvailableTerminal(instance, `update visibility for ${cwd}`); + if (!currentInstance) { + continue; + } + + const isForeground = this._terminalService.foregroundInstances.includes(currentInstance); + const isForceVisible = forceForegroundTerminalIds.includes(currentInstance.instanceId); + const belongsToActiveSession = cwd === activeKey; + if ((belongsToActiveSession || isForceVisible) && !isForeground) { + toShow.push(currentInstance); + } else if (!belongsToActiveSession && !isForceVisible && isForeground) { + toHide.push(currentInstance); + } + } + + for (const instance of toShow) { + const availableInstance = this._getAvailableTerminal(instance, 'show background terminal'); + if (availableInstance) { + await this._terminalService.showBackgroundTerminal(availableInstance, true); + } + } + for (const instance of toHide) { + const availableInstance = this._getAvailableTerminal(instance, 'move terminal to background'); + if (availableInstance) { + this._terminalService.moveToBackground(availableInstance); + } + } + + // Set the terminal with the most recent command as active + const foreground = this._terminalService.foregroundInstances; + let mostRecent: ITerminalInstance | undefined; + let mostRecentTimestamp = -1; + for (const instance of foreground) { + const cmdDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + const lastCmd = cmdDetection?.commands.at(-1); + if (lastCmd && lastCmd.timestamp > mostRecentTimestamp) { + mostRecentTimestamp = lastCmd.timestamp; + mostRecent = instance; + } + } + if (mostRecent) { + this._terminalService.setActiveInstance(mostRecent); + } + } + + private async _closeTerminalsForPath(fsPath: string): Promise { const key = fsPath.toLowerCase(); - const instanceId = this._pathToInstanceId.get(key); - if (instanceId !== undefined) { - const instance = this._terminalService.getInstanceFromId(instanceId); - if (instance) { - this._terminalService.safeDisposeTerminal(instance); - this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instanceId}`); + for (const instance of [...this._terminalService.instances]) { + try { + const cwd = (await instance.getInitialCwd()).toLowerCase(); + if (cwd === key) { + const availableInstance = this._getAvailableTerminal(instance, `close archived terminal for ${fsPath}`); + if (!availableInstance) { + continue; + } + this._terminalService.safeDisposeTerminal(availableInstance); + this._logService.trace(`[SessionsTerminal] Closed archived terminal ${availableInstance.instanceId}`); + } + } catch { + // ignore + } + } + } + + async dumpTracking(): Promise { + console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? ''}`); + console.log('[SessionsTerminal] === All Terminals ==='); + for (const instance of this._terminalService.instances) { + let cwd = ''; + try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ } + const isForeground = this._terminalService.foregroundInstances.includes(instance); + console.log(` ${instance.instanceId} - ${cwd} - ${isForeground ? 'foreground' : 'background'}`); + } + } + + async showAllTerminals(): Promise { + for (const instance of this._terminalService.instances) { + if (!this._terminalService.foregroundInstances.includes(instance)) { + await this._terminalService.showBackgroundTerminal(instance, true); + this._logService.trace(`[SessionsTerminal] Moved terminal ${instance.instanceId} to foreground`); } - this._pathToInstanceId.delete(key); } } } @@ -143,16 +300,32 @@ class OpenSessionInTerminalAction extends Action2 { id: 'agentSession.openInTerminal', title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, + toggled: { + condition: SessionsTerminalViewVisibleContext, + title: localize('hideTerminal', "Hide Terminal"), + }, menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarSessionMenu, group: 'navigation', order: 9, - when: IsAuxiliaryWindowContext.toNegated() + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) }] }); } override async run(_accessor: ServicesAccessor): Promise { + const layoutService = _accessor.get(IWorkbenchLayoutService); + const viewsService = _accessor.get(IViewsService); + + // Toggle: if panel is visible and the terminal view is active, hide it. + // If the panel is visible but showing another view, open the terminal instead. + if (layoutService.isVisible(Parts.PANEL_PART)) { + if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) { + layoutService.setPartHidden(true, Parts.PANEL_PART); + return; + } + } + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); const sessionsManagementService = _accessor.get(ISessionsManagementService); const pathService = _accessor.get(IPathService); @@ -160,7 +333,44 @@ class OpenSessionInTerminalAction extends Action2 { const activeSession = sessionsManagementService.activeSession.get(); const cwd = getSessionCwd(activeSession) ?? await pathService.userHome(); await contribution.ensureTerminal(cwd, true); + viewsService.openView(TERMINAL_VIEW_ID); } } registerAction2(OpenSessionInTerminalAction); + +class DumpTerminalTrackingAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.dumpTerminalTracking', + title: localize2('dumpTerminalTracking', "Dump Terminal Tracking"), + f1: true, + }); + } + + override async run(): Promise { + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + await contribution.dumpTracking(); + } +} + +registerAction2(DumpTerminalTrackingAction); + +class ShowAllTerminalsAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.showAllTerminals', + title: localize2('showAllTerminals', "Show All Terminals"), + f1: true, + }); + } + + override async run(): Promise { + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + await contribution.showAllTerminals(); + } +} + +registerAction2(ShowAllTerminalsAction); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts new file mode 100644 index 0000000000000..3ab501ba1dfd7 --- /dev/null +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -0,0 +1,667 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionData } from '../../../sessions/common/sessionData.js'; +import { ISessionsChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; +import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; + +const HOME_DIR = URI.file('/home/user'); + +class TestLogService extends NullLogService { + readonly traces: string[] = []; + + override trace(message: string, ...args: unknown[]): void { + this.traces.push([message, ...args].join(' ')); + } +} + +type TestTerminalInstance = ITerminalInstance & { + _testCommandHistory: { timestamp: number }[]; + _testSetDisposed(disposed: boolean): void; + _testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void; +}; + +function makeAgentSession(opts: { + repository?: URI; + worktree?: URI; + providerType?: string; + isArchived?: boolean; +}): ISessionData { + const repo = opts.repository || opts.worktree ? { + uri: opts.repository ?? opts.worktree!, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchProtected: undefined, + } : undefined; + return { + sessionId: 'test:session', + resource: URI.parse('file:///session'), + providerId: 'test', + sessionType: opts.providerType ?? AgentSessionProviders.Local, + icon: Codicon.copilot, + createdAt: new Date(), + workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo] } : undefined), + title: observableValue('test.title', 'Test Session'), + updatedAt: observableValue('test.updatedAt', new Date()), + status: observableValue('test.status', 0), + changes: observableValue('test.changes', []), + modelId: observableValue('test.modelId', undefined), + mode: observableValue('test.mode', undefined), + loading: observableValue('test.loading', false), + isArchived: observableValue('test.isArchived', opts.isArchived ?? false), + isRead: observableValue('test.isRead', true), + lastTurnEnd: observableValue('test.lastTurnEnd', undefined), + description: observableValue('test.description', undefined), + pullRequestUri: observableValue('test.pullRequestUri', undefined), + }; +} + +function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISessionData { + const repo = opts.repository || opts.worktree ? { + uri: opts.repository ?? opts.worktree!, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchProtected: undefined, + } : undefined; + return { + sessionId: 'test:non-agent', + resource: URI.parse('file:///session'), + providerId: 'test', + sessionType: opts.providerType ?? AgentSessionProviders.Local, + icon: Codicon.copilot, + createdAt: new Date(), + workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo] } : undefined), + title: observableValue('test.title', 'Test Session'), + updatedAt: observableValue('test.updatedAt', new Date()), + status: observableValue('test.status', 0), + changes: observableValue('test.changes', []), + modelId: observableValue('test.modelId', undefined), + mode: observableValue('test.mode', undefined), + loading: observableValue('test.loading', false), + isArchived: observableValue('test.isArchived', false), + isRead: observableValue('test.isRead', true), + lastTurnEnd: observableValue('test.lastTurnEnd', undefined), + description: observableValue('test.description', undefined), + pullRequestUri: observableValue('test.pullRequestUri', undefined), + }; +} + +function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { + const commandHistory: { timestamp: number }[] = []; + let isDisposed = false; + let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig']; + const capabilities = { + get(cap: TerminalCapability) { + if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { + return { commands: commandHistory } as unknown as ICommandDetectionCapability; + } + return undefined; + } + } as ITerminalCapabilityStore; + + return { + instanceId: id, + get isDisposed() { return isDisposed; }, + get shellLaunchConfig() { return shellLaunchConfig; }, + getInitialCwd: () => Promise.resolve(cwd), + capabilities, + _testCommandHistory: commandHistory, + _testSetDisposed(disposed: boolean) { + isDisposed = disposed; + }, + _testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) { + shellLaunchConfig = value; + }, + } as unknown as TestTerminalInstance; +} + +function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void { + (instance as TestTerminalInstance)._testCommandHistory.push({ timestamp }); +} + +suite('SessionsTerminalContribution', () => { + const store = new DisposableStore(); + let contribution: SessionsTerminalContribution; + let activeSessionObs: ReturnType>; + let onDidChangeSessions: Emitter; + let onDidCreateInstance: Emitter; + + let createdTerminals: { cwd: URI }[]; + let activeInstanceSet: number[]; + let focusCalls: number; + let disposedInstances: ITerminalInstance[]; + let nextInstanceId: number; + let terminalInstances: Map; + let backgroundedInstances: Set; + let moveToBackgroundCalls: number[]; + let showBackgroundCalls: number[]; + let disposeOnCreatePaths: Set; + let logService: TestLogService; + + setup(() => { + createdTerminals = []; + activeInstanceSet = []; + focusCalls = 0; + disposedInstances = []; + nextInstanceId = 1; + terminalInstances = new Map(); + backgroundedInstances = new Set(); + moveToBackgroundCalls = []; + showBackgroundCalls = []; + disposeOnCreatePaths = new Set(); + logService = new TestLogService(); + + const instantiationService = store.add(new TestInstantiationService()); + + activeSessionObs = observableValue('activeSession', undefined); + onDidChangeSessions = store.add(new Emitter()); + onDidCreateInstance = store.add(new Emitter()); + + instantiationService.stub(ILogService, logService); + + instantiationService.stub(ISessionsManagementService, new class extends mock() { + override activeSession = activeSessionObs; + override readonly onDidChangeSessions = onDidChangeSessions.event; + }); + + instantiationService.stub(ITerminalService, new class extends mock() { + override onDidCreateInstance = onDidCreateInstance.event; + override get instances(): readonly ITerminalInstance[] { + return [...terminalInstances.values()]; + } + override get foregroundInstances(): readonly ITerminalInstance[] { + return [...terminalInstances.values()].filter(i => !backgroundedInstances.has(i.instanceId)); + } + override async createTerminal(opts?: any): Promise { + const id = nextInstanceId++; + const cwdUri: URI | undefined = opts?.config?.cwd; + const cwdStr = cwdUri?.fsPath ?? ''; + const instance = makeTerminalInstance(id, cwdStr); + createdTerminals.push({ cwd: opts?.config?.cwd }); + terminalInstances.set(id, instance); + if (disposeOnCreatePaths.has(cwdStr)) { + instance._testSetDisposed(true); + terminalInstances.delete(id); + } + return instance; + } + override getInstanceFromId(id: number): ITerminalInstance | undefined { + return terminalInstances.get(id); + } + override setActiveInstance(instance: ITerminalInstance): void { + activeInstanceSet.push(instance.instanceId); + } + override async focusActiveInstance(): Promise { + focusCalls++; + } + override async safeDisposeTerminal(instance: ITerminalInstance): Promise { + disposedInstances.push(instance); + (instance as TestTerminalInstance)._testSetDisposed(true); + terminalInstances.delete(instance.instanceId); + backgroundedInstances.delete(instance.instanceId); + } + override moveToBackground(instance: ITerminalInstance): void { + backgroundedInstances.add(instance.instanceId); + moveToBackgroundCalls.push(instance.instanceId); + } + override async showBackgroundTerminal(instance: ITerminalInstance): Promise { + backgroundedInstances.delete(instance.instanceId); + showBackgroundCalls.push(instance.instanceId); + } + }); + + instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + instantiationService.stub(IViewsService, new class extends mock() { + override isViewVisible(): boolean { return false; } + override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; + }); + + contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- Background provider: uses worktree/repository path --- + + test('creates a terminal at the worktree for a background session', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ worktree: worktreeUri, repository: URI.file('/repo'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, worktreeUri.fsPath); + }); + + test('falls back to repository when worktree is undefined for a background session', async () => { + const repoUri = URI.file('/repo'); + const session = makeAgentSession({ repository: repoUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, repoUri.fsPath); + }); + + // --- Non-background providers: use home directory --- + + test('uses home directory for a cloud agent session', async () => { + const session = makeAgentSession({ worktree: URI.file('/worktree'), repository: URI.file('/repo'), providerType: AgentSessionProviders.Cloud }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + }); + + test('uses home directory for a local agent session', async () => { + const session = makeAgentSession({ worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + }); + + test('uses home directory for a non-agent session', async () => { + const session = makeNonAgentSession({ repository: URI.file('/repo') }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + }); + + test('does not recreate terminal when multiple non-background sessions share the home directory', async () => { + const session1 = makeAgentSession({ providerType: AgentSessionProviders.Cloud }); + activeSessionObs.set(session1, undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + + // Different non-background session — same home dir, no new terminal + const session2 = makeAgentSession({ providerType: AgentSessionProviders.Local }); + activeSessionObs.set(session2, undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + }); + + test('does not create a terminal when there is no active session', async () => { + activeSessionObs.set(undefined, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 0); + }); + + test('does not recreate terminal for the same path', async () => { + const worktreeUri = URI.file('/worktree'); + const session1 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session1, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + + // Setting a different session with the same worktree should not create a new terminal + const session2 = makeAgentSession({ worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session2, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1); + }); + + test('creates new terminal when switching to a different background path', async () => { + const worktree1 = URI.file('/worktree1'); + const worktree2 = URI.file('/worktree2'); + + activeSessionObs.set(makeAgentSession({ worktree: worktree1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: worktree2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 2); + assert.strictEqual(createdTerminals[1].cwd.fsPath, worktree2.fsPath); + }); + + // --- ensureTerminal --- + + test('ensureTerminal creates terminal and sets it active', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1); + assert.strictEqual(createdTerminals[0].cwd.fsPath, cwd.fsPath); + assert.strictEqual(activeInstanceSet.length, 1); + assert.strictEqual(focusCalls, 0); + }); + + test('ensureTerminal focuses when requested', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, true); + + assert.strictEqual(focusCalls, 1); + }); + + test('ensureTerminal reuses existing terminal for same path', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal'); + assert.strictEqual(activeInstanceSet.length, 1, 'should only set active instance on creation'); + }); + + test('ensureTerminal creates new terminal for different path', async () => { + await contribution.ensureTerminal(URI.file('/cwd1'), false); + await contribution.ensureTerminal(URI.file('/cwd2'), false); + + assert.strictEqual(createdTerminals.length, 2); + }); + + test('ensureTerminal path comparison is case-insensitive', async () => { + await contribution.ensureTerminal(URI.file('/Test/CWD'), false); + await contribution.ensureTerminal(URI.file('/test/cwd'), false); + + assert.strictEqual(createdTerminals.length, 1, 'should match case-insensitively'); + }); + + test('ensureTerminal does not activate a terminal disposed during creation', async () => { + const cwd = URI.file('/test-cwd'); + disposeOnCreatePaths.add(cwd.fsPath); + + const instances = await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(instances.length, 0); + assert.strictEqual(activeInstanceSet.length, 0); + assert.ok(logService.traces.some(message => message.includes(`Cannot activate created terminal for ${cwd.fsPath}; terminal 1 is no longer available`))); + }); + + // --- onDidChangeSessions (archived) --- + + test('closes terminals when session is archived', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + assert.strictEqual(createdTerminals.length, 1); + + const session = makeAgentSession({ + isArchived: true, + worktree: worktreeUri, + }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1); + }); + + test('does not close terminals when session is not archived', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const session = makeAgentSession({ + isArchived: false, + worktree: worktreeUri, + }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0); + }); + + test('does not close terminals when archived session has no worktree', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + const session = makeAgentSession({ isArchived: true }); + onDidChangeSessions.fire({ added: [], removed: [], changed: [session] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 0); + }); + + test('closes terminals when session is removed', async () => { + const worktreeUri = URI.file('/worktree'); + await contribution.ensureTerminal(worktreeUri, false); + + assert.strictEqual(createdTerminals.length, 1); + + const session = makeAgentSession({ worktree: worktreeUri }); + onDidChangeSessions.fire({ added: [], removed: [session], changed: [] }); + await tick(); + + assert.strictEqual(disposedInstances.length, 1); + }); + + // --- switching back to previously used path reuses terminal --- + + test('switching back to a previously used background path reuses the existing terminal', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 2); + + // Switch back to cwd1 - should reuse terminal, not create a new one + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1'); + }); + + // --- Terminal visibility management (cwd-based) --- + + test('hides terminals from previous session when switching to a new session', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // The first terminal (id=1) should have been moved to background + assert.ok(moveToBackgroundCalls.includes(1), 'terminal for cwd1 should be backgrounded'); + assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should remain backgrounded'); + }); + + test('shows previously hidden terminals when switching back to their session', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Switch back to cwd1 + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Terminal for cwd1 (id=1) should be shown again + assert.ok(showBackgroundCalls.includes(1), 'terminal for cwd1 should be shown'); + assert.ok(!backgroundedInstances.has(1), 'terminal for cwd1 should be foreground'); + // Terminal for cwd2 (id=2) should now be backgrounded + assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded'); + }); + + test('only terminals of the active session are visible after multiple switches', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + const cwd3 = URI.file('/cwd3'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd3, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Only terminal for cwd3 (id=3) should be foreground + assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should be backgrounded'); + assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded'); + assert.ok(!backgroundedInstances.has(3), 'terminal for cwd3 should be foreground'); + }); + + test('shows pre-existing terminal with matching cwd instead of creating a new one', async () => { + // Manually add a terminal that already exists with a matching cwd + const cwd = URI.file('/worktree'); + const existingInstance = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(existingInstance.instanceId, existingInstance); + backgroundedInstances.add(existingInstance.instanceId); + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 0, 'should reuse existing terminal, not create a new one'); + assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal'); + }); + + test('does not background a restored terminal that is disposed before cwd resolves', async () => { + let resolveInitialCwd: ((cwd: string) => void) | undefined; + const restoredInstance = makeTerminalInstance(nextInstanceId++, '/restored'); + restoredInstance._testSetShellLaunchConfig({ attachPersistentProcess: {} as never } as ITerminalInstance['shellLaunchConfig']); + restoredInstance.getInitialCwd = () => new Promise(resolve => { + resolveInitialCwd = resolve; + }); + terminalInstances.set(restoredInstance.instanceId, restoredInstance); + + activeSessionObs.set(makeAgentSession({ worktree: URI.file('/active'), providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + onDidCreateInstance.fire(restoredInstance); + restoredInstance._testSetDisposed(true); + terminalInstances.delete(restoredInstance.instanceId); + resolveInitialCwd?.('/other'); + await tick(); + + assert.ok(!moveToBackgroundCalls.includes(restoredInstance.instanceId), 'disposed restored terminal should not be backgrounded'); + assert.ok(logService.traces.some(message => message.includes('Cannot hide restored terminal for /other; terminal') && message.includes('is no longer available'))); + }); + + test('hides pre-existing terminal with non-matching cwd when session changes', async () => { + // Manually add a terminal that already exists with a different cwd + const otherInstance = makeTerminalInstance(nextInstanceId++, '/other/path'); + terminalInstances.set(otherInstance.instanceId, otherInstance); + + const cwd = URI.file('/worktree'); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(moveToBackgroundCalls.includes(otherInstance.instanceId), 'non-matching terminal should be backgrounded'); + }); + + test('ensureTerminal finds a backgrounded terminal instead of creating a new one', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + const instanceId = activeInstanceSet[0]; + + // Manually background it + backgroundedInstances.add(instanceId); + + // ensureTerminal should find it by cwd, not create a new one + const result = await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal'); + assert.strictEqual(result[0].instanceId, instanceId, 'should return the existing backgrounded terminal'); + }); + + test('visibility is determined by initial cwd, not by stored IDs', async () => { + // Create a terminal externally (not via ensureTerminal) with a known cwd + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + const ext1 = makeTerminalInstance(nextInstanceId++, cwd1.fsPath); + const ext2 = makeTerminalInstance(nextInstanceId++, cwd2.fsPath); + terminalInstances.set(ext1.instanceId, ext1); + terminalInstances.set(ext2.instanceId, ext2); + + // Switch to cwd1 — ext1 should stay visible, ext2 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(!backgroundedInstances.has(ext1.instanceId), 'ext1 should be foreground (matching cwd)'); + assert.ok(backgroundedInstances.has(ext2.instanceId), 'ext2 should be backgrounded (non-matching cwd)'); + + // Switch to cwd2 — ext2 should be shown, ext1 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(backgroundedInstances.has(ext1.instanceId), 'ext1 should now be backgrounded'); + assert.ok(!backgroundedInstances.has(ext2.instanceId), 'ext2 should now be foreground'); + }); + + // --- Most-recent-command active terminal selection --- + + test('sets the terminal with the most recent command as active after visibility update', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); + + // t1 ran a command at timestamp 100, t2 at timestamp 200 (more recent) + addCommandToInstance(t1, 100); + addCommandToInstance(t2, 200); + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // The most recent setActiveInstance call should be for t2 + assert.strictEqual(activeInstanceSet.at(-1), t2.instanceId, 'should set the terminal with the most recent command as active'); + }); + + test('does not change active instance when no terminals have command history', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); + + const activeCountBefore = activeInstanceSet.length; + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // No setActiveInstance calls from visibility update since no commands were run + assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists'); + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css index e5203a11e7f67..9883b93456e88 100644 --- a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css +++ b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css @@ -22,93 +22,71 @@ pointer-events: none; } -/* ---- Card ---- */ +/* ---- Card (borderless, centered content) ---- */ .sessions-welcome-card { - width: 460px; - max-width: 90vw; - padding: 32px; - border-radius: 8px; - background: var(--vscode-sideBar-background); - border: 1px solid var(--vscode-widget-border, transparent); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; - gap: 24px; + align-items: center; + gap: 20px; + text-align: center; } /* ---- Header ---- */ +.sessions-welcome-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.sessions-welcome-header .sessions-welcome-icon .codicon { + font-size: 96px; + color: var(--vscode-descriptionForeground); +} + .sessions-welcome-header h2 { - margin: 0 0 4px 0; - font-size: 18px; + margin: 0; + font-size: 22px; font-weight: 600; color: var(--vscode-foreground); } -.sessions-welcome-subtitle { +.sessions-welcome-header .sessions-welcome-subtitle { margin: 0; - font-size: 13px; + font-size: 14px; color: var(--vscode-descriptionForeground); } -/* ---- Step List ---- */ +/* ---- Action Area ---- */ -.sessions-welcome-step-list { +.sessions-welcome-action-area { display: flex; flex-direction: column; - gap: 8px; -} - -.sessions-welcome-step-item { - display: flex; align-items: center; gap: 10px; - padding: 8px 12px; - border-radius: 4px; - font-size: 13px; - color: var(--vscode-descriptionForeground); - background: transparent; - transition: background 150ms, color 150ms; + width: 320px; } -.sessions-welcome-step-item.current { - background: var(--vscode-list-hoverBackground); - color: var(--vscode-foreground); -} - -.sessions-welcome-step-item.satisfied { - color: var(--vscode-testing-iconPassed, var(--vscode-descriptionForeground)); +.sessions-welcome-action-area .monaco-button { + width: 100%; + padding: 10px 16px; + font-size: 14px; } -/* ---- Step Indicator (number or check icon) ---- */ +/* ---- Spinner ---- */ -.sessions-welcome-step-indicator { +.sessions-welcome-spinner { display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; - border-radius: 50%; - font-size: 12px; - font-weight: 600; - flex-shrink: 0; - border: 1px solid var(--vscode-descriptionForeground); + gap: 8px; + font-size: 13px; color: var(--vscode-descriptionForeground); } -.sessions-welcome-step-item.current .sessions-welcome-step-indicator { - border-color: var(--vscode-focusBorder); - color: var(--vscode-focusBorder); -} - -.sessions-welcome-step-item.satisfied .sessions-welcome-step-indicator { - border-color: var(--vscode-testing-iconPassed, var(--vscode-focusBorder)); - color: var(--vscode-testing-iconPassed, var(--vscode-focusBorder)); -} - -.sessions-welcome-step-indicator.loading { - border: none; +.sessions-welcome-spinner .codicon-loading { animation: sessions-spin 1.5s linear infinite; } @@ -116,28 +94,6 @@ to { transform: rotate(360deg); } } -.sessions-welcome-step-title { - flex: 1; -} - -/* ---- Action Area ---- */ - -.sessions-welcome-action-area { - display: flex; - flex-direction: column; - gap: 10px; -} - -.sessions-welcome-action-description { - margin: 0; - font-size: 13px; - color: var(--vscode-descriptionForeground); -} - -.sessions-welcome-action-area .monaco-button { - width: 100%; -} - /* ---- Error ---- */ .sessions-welcome-error { diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts deleted file mode 100644 index 1eca99dd18359..0000000000000 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeOverlay.ts +++ /dev/null @@ -1,145 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/welcomeOverlay.css'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { $, append } from '../../../../base/browser/dom.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { ISessionsWelcomeService, ISessionsWelcomeStep } from '../common/sessionsWelcomeService.js'; -import { localize } from '../../../../nls.js'; - -export class SessionsWelcomeOverlay extends Disposable { - - private readonly overlay: HTMLElement; - private actionRunning = false; - private runningStepId: string | undefined; - - private readonly _onDidDismiss = this._register(new Emitter()); - readonly onDidDismiss: Event = this._onDidDismiss.event; - - constructor( - container: HTMLElement, - @ISessionsWelcomeService private readonly welcomeService: ISessionsWelcomeService, - ) { - super(); - - // Root overlay element — blocks the entire window - this.overlay = append(container, $('.sessions-welcome-overlay')); - this._register({ dispose: () => this.overlay.remove() }); - - const card = append(this.overlay, $('.sessions-welcome-card')); - - // Header - const header = append(card, $('.sessions-welcome-header')); - append(header, $('h2', undefined, localize('welcomeTitle', "VS Code - Sessions"))); - append(header, $('p.sessions-welcome-subtitle', undefined, localize('welcomeSubtitle', "Complete the following steps to get started."))); - - // Step list container - const stepList = append(card, $('.sessions-welcome-step-list')); - - // Current step action area - const actionArea = append(card, $('.sessions-welcome-action-area')); - const actionDescription = append(actionArea, $('p.sessions-welcome-action-description')); - const actionButton = this._register(new Button(actionArea, { ...defaultButtonStyles })); - - // Track state for error display - const errorContainer = append(actionArea, $('p.sessions-welcome-error')); - errorContainer.style.display = 'none'; - - // Reactively render the step list and current step - this._register(autorun(reader => { - const steps = this.welcomeService.steps.read(reader); - const current = this.welcomeService.currentStep.read(reader); - const isComplete = this.welcomeService.isComplete.read(reader); - - if (isComplete) { - this.dismiss(); - return; - } - - // Render step indicators - this.renderStepList(stepList, steps, current); - - // Render current step action area - if (current) { - actionDescription.textContent = current.description; - actionButton.label = current.actionLabel; - actionButton.enabled = !this.actionRunning; - actionArea.style.display = ''; - } else { - actionArea.style.display = 'none'; - } - })); - - // Button click handler - this._register(actionButton.onDidClick(async () => { - const current = this.welcomeService.currentStep.get(); - if (!current || this.actionRunning) { - return; - } - - this.actionRunning = true; - this.runningStepId = current.id; - actionButton.enabled = false; - errorContainer.style.display = 'none'; - - // Re-render step list to show spinner - this.renderStepList(stepList, this.welcomeService.steps.get(), current); - - try { - await current.action(); - } catch (err) { - errorContainer.textContent = localize('stepError', "Something went wrong. Please try again."); - errorContainer.style.display = ''; - } finally { - this.actionRunning = false; - this.runningStepId = undefined; - actionButton.enabled = true; - } - })); - } - - private renderStepList(container: HTMLElement, steps: readonly ISessionsWelcomeStep[], current: ISessionsWelcomeStep | undefined): void { - container.textContent = ''; - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const satisfied = step.isSatisfied.get(); - const isCurrent = step === current; - - const stepEl = append(container, $( - '.sessions-welcome-step-item' + - (satisfied ? '.satisfied' : '') + - (isCurrent ? '.current' : '') - )); - - // Step number / check icon / spinner - const indicator = append(stepEl, $('.sessions-welcome-step-indicator')); - if (satisfied) { - indicator.appendChild(renderIcon(Codicon.check)); - } else if (this.runningStepId === step.id) { - indicator.appendChild(renderIcon(Codicon.loading)); - indicator.classList.add('loading'); - } else { - indicator.textContent = String(i + 1); - } - - // Step title - append(stepEl, $('span.sessions-welcome-step-title', undefined, step.title)); - } - } - - private dismiss(): void { - this.overlay.classList.add('sessions-welcome-overlay-dismissed'); - this._onDidDismiss.fire(); - // Allow CSS transition to finish before disposing - const handle = setTimeout(() => this.dispose(), 200); - this._register(toDisposable(() => clearTimeout(handle))); - } -} diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeService.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeService.ts deleted file mode 100644 index e650e2a7b5c92..0000000000000 --- a/src/vs/sessions/contrib/welcome/browser/sessionsWelcomeService.ts +++ /dev/null @@ -1,59 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, derived, observableValue, transaction } from '../../../../base/common/observable.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; -import { ISessionsWelcomeService, ISessionsWelcomeStep } from '../common/sessionsWelcomeService.js'; - -export class SessionsWelcomeService extends Disposable implements ISessionsWelcomeService { - - declare readonly _serviceBrand: undefined; - - private readonly _steps = observableValue(this, []); - private readonly _initializedDeferred = new DeferredPromise(); - - readonly steps: IObservable = this._steps; - - readonly isComplete: IObservable = derived(this, reader => { - const steps = this._steps.read(reader); - if (steps.length === 0) { - return true; - } - return steps.every(step => step.isSatisfied.read(reader)); - }); - - readonly whenInitialized: Promise = this._initializedDeferred.p; - - readonly currentStep: IObservable = derived(this, reader => { - const steps = this._steps.read(reader); - return steps.find(step => !step.isSatisfied.read(reader)); - }); - - registerStep(step: ISessionsWelcomeStep) { - transaction(tx => { - const current = this._steps.get(); - const updated = [...current, step].sort((a, b) => a.order - b.order); - this._steps.set(updated, tx); - }); - - return toDisposable(() => { - transaction(tx => { - const current = this._steps.get(); - this._steps.set(current.filter(s => s !== step), tx); - }); - }); - } - - /** - * Wait for all currently registered steps to finish their async initialization, - * then mark the service as initialized. - */ - async initialize(): Promise { - const steps = this._steps.get(); - await Promise.all(steps.map(s => s.initialized)); - this._initializedDeferred.complete(); - } -} diff --git a/src/vs/sessions/contrib/welcome/browser/steps/copilotChatInstallStep.ts b/src/vs/sessions/contrib/welcome/browser/steps/copilotChatInstallStep.ts deleted file mode 100644 index c9883d26f6225..0000000000000 --- a/src/vs/sessions/contrib/welcome/browser/steps/copilotChatInstallStep.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IObservable, observableFromEvent } from '../../../../../base/common/observable.js'; -import { localize } from '../../../../../nls.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; -import { IExtensionsWorkbenchService } from '../../../../../workbench/contrib/extensions/common/extensions.js'; -import { ISessionsWelcomeStep } from '../../common/sessionsWelcomeService.js'; - -export class CopilotChatInstallStep implements ISessionsWelcomeStep { - - readonly id = 'copilotChat.install'; - readonly title = localize('copilotChatInstall.title', "Install Copilot Chat"); - readonly description = localize('copilotChatInstall.description', "The Copilot Chat extension is required for Agent Sessions."); - readonly actionLabel = localize('copilotChatInstall.action', "Install Copilot Chat"); - readonly order = 10; - - readonly isSatisfied: IObservable; - readonly initialized: Promise; - - private readonly chatExtensionId: string; - - constructor( - @IExtensionService private readonly extensionService: IExtensionService, - @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, - @IProductService private readonly productService: IProductService, - ) { - this.chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId ?? ''; - - this.isSatisfied = observableFromEvent( - this, - this.extensionService.onDidChangeExtensionsStatus, - () => this.extensionService.extensions.some( - ext => ext.identifier.value.toLowerCase() === this.chatExtensionId.toLowerCase() - ) - ); - - // Wait until the extension host has loaded installed extensions - this.initialized = this.extensionService.whenInstalledExtensionsRegistered().then(() => { }); - } - - async action(): Promise { - if (!this.chatExtensionId) { - return; - } - - await this.extensionsWorkbenchService.install(this.chatExtensionId, { - enable: true, - isApplicationScoped: true, - isMachineScoped: false, - installEverywhere: true, - installPreReleaseVersion: this.productService.quality !== 'stable', - }); - } -} diff --git a/src/vs/sessions/contrib/welcome/browser/steps/gitHubSignInStep.ts b/src/vs/sessions/contrib/welcome/browser/steps/gitHubSignInStep.ts deleted file mode 100644 index 26ea8f49f75e2..0000000000000 --- a/src/vs/sessions/contrib/welcome/browser/steps/gitHubSignInStep.ts +++ /dev/null @@ -1,56 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; -import { localize } from '../../../../../nls.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; -import { ISessionsWelcomeStep } from '../../common/sessionsWelcomeService.js'; - -export class GitHubSignInStep extends Disposable implements ISessionsWelcomeStep { - - readonly id = 'github.signIn'; - readonly title = localize('githubSignIn.title', "Sign In with GitHub"); - readonly description = localize('githubSignIn.description', "Sign in to your GitHub account to use Agent Sessions."); - readonly actionLabel = localize('githubSignIn.action', "Sign In"); - readonly order = 20; - - readonly isSatisfied: IObservable; - readonly initialized: Promise; - - private readonly _isSatisfied = observableValue(this, false); - - constructor( - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, - ) { - super(); - - this.isSatisfied = this._isSatisfied; - - // Listen for account changes - this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => { - this._isSatisfied.set(this.isSignedInWithValidToken(account), undefined); - })); - - // Check initial state and mark initialized when resolved - this.initialized = this.defaultAccountService.getDefaultAccount().then(account => { - this._isSatisfied.set(this.isSignedInWithValidToken(account), undefined); - }); - } - - /** - * Returns `true` when the user is signed in and their token has not - * expired. A `null` value for {@link IDefaultAccount.entitlementsData} - * indicates the OAuth token is expired or revoked (HTTP 401). - */ - private isSignedInWithValidToken(account: IDefaultAccount | null | undefined): boolean { - return account !== null && account !== undefined && account.entitlementsData !== null; - } - - async action(): Promise { - await this.defaultAccountService.signIn(); - } -} diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index fbc9149ceeb86..29634287b095e 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -3,32 +3,128 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import './media/welcomeOverlay.css'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; -import { localize2 } from '../../../../nls.js'; -import { ISessionsWelcomeService } from '../common/sessionsWelcomeService.js'; -import { SessionsWelcomeService } from './sessionsWelcomeService.js'; -import { SessionsWelcomeOverlay } from './sessionsWelcomeOverlay.js'; -import { CopilotChatInstallStep } from './steps/copilotChatInstallStep.js'; -import { GitHubSignInStep } from './steps/gitHubSignInStep.js'; -import { SessionsWelcomeCompleteContext } from '../../../common/contextkeys.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; -// Register the service -registerSingleton(ISessionsWelcomeService, SessionsWelcomeService, InstantiationType.Eager); +class SessionsWelcomeOverlay extends Disposable { + + private readonly overlay: HTMLElement; + + constructor( + container: HTMLElement, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @ICommandService private readonly commandService: ICommandService, + @IExtensionService private readonly extensionService: IExtensionService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.overlay = append(container, $('.sessions-welcome-overlay')); + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); + this.overlay.setAttribute('aria-label', localize('welcomeOverlay.aria', "Sign in to use Sessions")); + this._register(toDisposable(() => this.overlay.remove())); + + const card = append(this.overlay, $('.sessions-welcome-card')); + + // Header — large icon + title, centered + const header = append(card, $('.sessions-welcome-header')); + const iconEl = append(header, $('span.sessions-welcome-icon')); + iconEl.appendChild(renderIcon(Codicon.agent)); + append(header, $('h2', undefined, localize('welcomeTitle', "Sign in to use Sessions"))); + append(header, $('p.sessions-welcome-subtitle', undefined, localize('welcomeSubtitle', "Agent-powered development"))); + + // Action area + const actionArea = append(card, $('.sessions-welcome-action-area')); + const actionButton = this._register(new Button(actionArea, { ...defaultButtonStyles })); + actionButton.label = localize('sessions.getStarted', "Get Started"); + + const spinnerContainer = append(actionArea, $('.sessions-welcome-spinner')); + spinnerContainer.style.display = 'none'; + + const errorContainer = append(actionArea, $('p.sessions-welcome-error')); + errorContainer.style.display = 'none'; + + this._register(actionButton.onDidClick(() => this._runSetup(actionButton, spinnerContainer, errorContainer))); + + // Focus the button so the overlay traps keyboard input + actionButton.focus(); + } + + private async _runSetup(button: Button, spinner: HTMLElement, error: HTMLElement): Promise { + button.enabled = false; + error.style.display = 'none'; + + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.settingUp', "Setting up…"))); + spinner.style.display = ''; + + try { + const success = await this.commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, { + dialogIcon: Codicon.agent, + dialogTitle: this.chatEntitlementService.anonymous ? + localize('sessions.startUsingSessions', "Start using Sessions") : + localize('sessions.signinRequired', "Sign in to use Sessions"), + }); + + if (success) { + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.restarting', "Completing setup…"))); + + this.logService.info('[sessions welcome] Restarting extension host after setup completion'); + const stopped = await this.extensionService.stopExtensionHosts( + localize('sessionsWelcome.restart', "Completing sessions setup") + ); + if (stopped) { + await this.extensionService.startExtensionHosts(); + } + } else { + button.enabled = true; + spinner.style.display = 'none'; + } + } catch (err) { + this.logService.error('[sessions welcome] Setup failed:', err); + error.textContent = localize('sessions.setupError', "Something went wrong. Please try again."); + error.style.display = ''; + button.enabled = true; + spinner.style.display = 'none'; + } + } + + dismiss(): void { + this.overlay.classList.add('sessions-welcome-overlay-dismissed'); + const handle = setTimeout(() => this.dispose(), 200); + this._register(toDisposable(() => clearTimeout(handle))); + } +} -class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { +export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -36,109 +132,124 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri private readonly watcherRef = this._register(new MutableDisposable()); constructor( - @ISessionsWelcomeService private readonly welcomeService: ISessionsWelcomeService, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IProductService private readonly productService: IProductService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(); - // Bind context key to the observable - this._register(bindContextKey( - SessionsWelcomeCompleteContext, - this.contextKeyService, - reader => this.welcomeService.isComplete.read(reader), - )); - - // Only proceed if the product is configured with a default chat agent if (!this.productService.defaultChatAgent?.chatExtensionId) { return; } - const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); - - this.registerSteps(); - this.welcomeService.initialize(); + // Allow automated tests to skip the welcome overlay entirely. + // Desktop: --skip-sessions-welcome CLI flag + // Web: ?skip-sessions-welcome query parameter + const envArgs = (this.environmentService as IWorkbenchEnvironmentService & { args?: Record }).args; + if (envArgs?.['skip-sessions-welcome']) { + return; + } + if (typeof globalThis.location !== 'undefined' && new URLSearchParams(globalThis.location.search).has('skip-sessions-welcome')) { + return; + } + const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); if (isFirstLaunch) { - // First launch: show the welcome overlay immediately this.showOverlay(); } else { - // Returning user: only show if Copilot Chat is not installed - this.showOverlayIfNeededAfterInit(); + this.showOverlayIfNeeded(); } } - private registerSteps(): void { - const stepStore = this._register(new DisposableStore()); - - // Step 1: Install Copilot Chat extension - const copilotStep = this.instantiationService.createInstance(CopilotChatInstallStep); - stepStore.add(this.welcomeService.registerStep(copilotStep)); - - // Step 2: Sign in with GitHub - const signInStep = this.instantiationService.createInstance(GitHubSignInStep); - stepStore.add(signInStep); - stepStore.add(this.welcomeService.registerStep(signInStep)); - } - - private async showOverlayIfNeededAfterInit(): Promise { - // Wait for extension host to know what's installed - await this.welcomeService.whenInitialized; - - // For returning users, only the Copilot Chat install state is a - // reliable trigger. Auth session restore races at startup, so we - // don't re-show the overlay just because sign-in hasn't resolved. - // If everything is already satisfied, skip. - if (this.welcomeService.isComplete.get()) { - this.watchForSignOutOrTokenExpiry(); - return; + private showOverlayIfNeeded(): void { + if (this._needsChatSetup()) { + this.showOverlay(); + } else { + this.watchEntitlementState(); } - - this.showOverlay(); } /** - * After the welcome flow has been completed once, watch for sign-out - * or token expiry and re-show the overlay when that happens. + * Watches entitlement and sentiment observables after setup has already + * completed. If the user's state changes such that setup is needed again + * (e.g. extension uninstalled/disabled), shows the welcome overlay. + * + * {@link ChatEntitlement.Unknown} is intentionally ignored here: it is + * almost always a transient state caused by a stale OAuth token being + * refreshed after an update. A genuine sign-out will be caught on the + * next app launch via the initial {@link showOverlayIfNeeded} check. */ - private watchForSignOutOrTokenExpiry(): void { - let wasComplete = this.welcomeService.isComplete.get(); + private watchEntitlementState(): void { + let setupComplete = !this._needsChatSetup(false); this.watcherRef.value = autorun(reader => { - const isComplete = this.welcomeService.isComplete.read(reader); - if (wasComplete && !isComplete) { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + const needsSetup = this._needsChatSetup(false); + if (setupComplete && needsSetup) { this.showOverlay(); } - wasComplete = isComplete; + setupComplete = !needsSetup; }); } + private _needsChatSetup(includeUnknown: boolean = true): boolean { + const { sentiment, entitlement } = this.chatEntitlementService; + if ( + !sentiment?.installed || // Extension not installed: run setup to install + sentiment?.disabled || // Extension disabled: run setup to enable + entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up + ( + includeUnknown && + entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up + !this.chatEntitlementService.anonymous // unless anonymous access is enabled + ) + ) { + return true; + } + + return false; + } + private showOverlay(): void { if (this.overlayRef.value) { - return; // overlay already shown + return; } + this.watcherRef.clear(); this.overlayRef.value = new DisposableStore(); + // Mark the welcome overlay as visible for titlebar disabling + const welcomeVisibleKey = SessionsWelcomeVisibleContext.bindTo(this.contextKeyService); + welcomeVisibleKey.set(true); + this.overlayRef.value.add(toDisposable(() => welcomeVisibleKey.reset())); + const overlay = this.overlayRef.value.add(this.instantiationService.createInstance( SessionsWelcomeOverlay, this.layoutService.mainContainer, )); - // Mark welcome as complete once the overlay is dismissed (all steps satisfied) - this.overlayRef.value.add(overlay.onDidDismiss(() => { - this.overlayRef.clear(); - this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - this.watchForSignOutOrTokenExpiry(); + // When setup completes (observables flip), dismiss and watch again + this.overlayRef.value.add(autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + if (!this._needsChatSetup()) { + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + overlay.dismiss(); + this.overlayRef.clear(); + this.watchEntitlementState(); + } })); } } registerWorkbenchContribution2(SessionsWelcomeContribution.ID, SessionsWelcomeContribution, WorkbenchPhase.BlockRestore); -// Debug command to reset welcome state so the overlay shows again on next launch registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/contrib/welcome/common/sessionsWelcomeService.ts b/src/vs/sessions/contrib/welcome/common/sessionsWelcomeService.ts deleted file mode 100644 index 6438542619d47..0000000000000 --- a/src/vs/sessions/contrib/welcome/common/sessionsWelcomeService.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IObservable } from '../../../../base/common/observable.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - -/** - * A single setup step in the sessions welcome flow. - * - * Steps are rendered sequentially by {@link order}. The overlay - * advances to the next step once {@link isSatisfied} becomes `true`. - */ -export interface ISessionsWelcomeStep { - /** Unique identifier for this step. */ - readonly id: string; - - /** Display title (localized). */ - readonly title: string; - - /** Short description shown when this step is the current step (localized). */ - readonly description: string; - - /** Reactive flag — `true` when this step's requirement is met. */ - readonly isSatisfied: IObservable; - - /** - * Resolves once the step has determined its initial satisfied state. - * Until this resolves, {@link isSatisfied} may not reflect reality. - */ - readonly initialized: Promise; - - /** Label for the primary action button (localized). */ - readonly actionLabel: string; - - /** - * Execute the primary action for this step. - * For example, install an extension or trigger a sign-in flow. - */ - action(): Promise; - - /** Sorting order — lower values run first. */ - readonly order: number; -} - -export const ISessionsWelcomeService = createDecorator('sessionsWelcomeService'); - -export interface ISessionsWelcomeService { - readonly _serviceBrand: undefined; - - /** All registered steps sorted by {@link ISessionsWelcomeStep.order}. */ - readonly steps: IObservable; - - /** `true` when every registered step is satisfied. */ - readonly isComplete: IObservable; - - /** Resolves once all registered steps have determined their initial state. */ - readonly whenInitialized: Promise; - - /** - * Wait for all currently registered steps to finish their async initialization, - * then mark the service as initialized. - */ - initialize(): Promise; - - /** The first unsatisfied step, or `undefined` when all are done. */ - readonly currentStep: IObservable; - - /** - * Register a new welcome step. - * The returned disposable removes the step on disposal. - */ - registerStep(step: ISessionsWelcomeStep): IDisposable; -} diff --git a/src/vs/sessions/contrib/welcome/test/browser/gitHubSignInStep.test.ts b/src/vs/sessions/contrib/welcome/test/browser/gitHubSignInStep.test.ts deleted file mode 100644 index 525ce00241ddf..0000000000000 --- a/src/vs/sessions/contrib/welcome/test/browser/gitHubSignInStep.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { GitHubSignInStep } from '../../browser/steps/gitHubSignInStep.js'; - -const VALID_ENTITLEMENTS: IDefaultAccount['entitlementsData'] = { - access_type_sku: 'free', - assigned_date: '', - can_signup_for_limited: false, - copilot_plan: 'free', - organization_login_list: [], - analytics_tracking_id: '', -}; - -function createAccount(entitlementsData?: IDefaultAccount['entitlementsData']): IDefaultAccount { - return { - authenticationProvider: { id: 'github', name: 'GitHub', enterprise: false }, - accountName: 'testuser', - sessionId: 'session-1', - enterprise: false, - entitlementsData, - }; -} - -suite('GitHubSignInStep', () => { - - const store = new DisposableStore(); - let onDidChange: Emitter; - let resolveGetAccount: (account: IDefaultAccount | null) => void; - - function createStep(): GitHubSignInStep { - onDidChange = store.add(new Emitter()); - let resolve: (account: IDefaultAccount | null) => void; - const getAccountPromise = new Promise(r => resolve = r); - resolveGetAccount = resolve!; - - const mockService = { - _serviceBrand: undefined, - onDidChangeDefaultAccount: onDidChange.event, - onDidChangePolicyData: Event.None, - policyData: null, - getDefaultAccount: () => getAccountPromise, - getDefaultAccountAuthenticationProvider: () => ({ id: 'github', name: 'GitHub', enterprise: false }), - setDefaultAccountProvider: () => { }, - refresh: async () => null, - signIn: async () => null, - signOut: async () => { }, - } satisfies IDefaultAccountService; - - const step = new GitHubSignInStep(mockService); - store.add(step); - return step; - } - - teardown(() => store.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('isSatisfied is true when account has valid entitlements', async () => { - const step = createStep(); - resolveGetAccount(createAccount(VALID_ENTITLEMENTS)); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), true); - }); - - test('isSatisfied is true when account has undefined entitlements', async () => { - const step = createStep(); - resolveGetAccount(createAccount(undefined)); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), true); // undefined means not configured, user is signed in - }); - - test('isSatisfied is false when account is null (signed out)', async () => { - const step = createStep(); - resolveGetAccount(null); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), false); - }); - - test('isSatisfied is false when account has null entitlements (token expired)', async () => { - const step = createStep(); - resolveGetAccount(createAccount(null)); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), false); - }); - - test('isSatisfied reacts to sign-out event', async () => { - const step = createStep(); - resolveGetAccount(createAccount(VALID_ENTITLEMENTS)); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), true); - - onDidChange.fire(null); - assert.strictEqual(step.isSatisfied.get(), false); - }); - - test('isSatisfied reacts to token expiry event', async () => { - const step = createStep(); - resolveGetAccount(createAccount(VALID_ENTITLEMENTS)); - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), true); - - onDidChange.fire(createAccount(null)); // token expired: account exists but entitlements null - assert.strictEqual(step.isSatisfied.get(), false); - }); - - test('isSatisfied recovers when token is refreshed', async () => { - const step = createStep(); - resolveGetAccount(createAccount(null)); // start with expired token - await step.initialized; - assert.strictEqual(step.isSatisfied.get(), false); - - onDidChange.fire(createAccount(VALID_ENTITLEMENTS)); - assert.strictEqual(step.isSatisfied.get(), true); - }); -}); diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts new file mode 100644 index 0000000000000..7105288c548b3 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class MockChatEntitlementService implements Partial { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeEntitlement = Event.None; + readonly onDidChangeSentiment = Event.None; + readonly onDidChangeAnonymous = Event.None; + readonly onDidChangeQuotaExceeded = Event.None; + readonly onDidChangeQuotaRemaining = Event.None; + + readonly entitlementObs: ISettableObservable = observableValue('entitlement', ChatEntitlement.Free); + readonly sentimentObs: ISettableObservable = observableValue('sentiment', { installed: true } as IChatSentiment); + readonly anonymousObs: ISettableObservable = observableValue('anonymous', false); + + readonly organisations = undefined; + readonly isInternal = false; + readonly sku = undefined; + readonly copilotTrackingId = undefined; + readonly quotas = {}; + readonly previewFeaturesDisabled = false; + + get entitlement(): ChatEntitlement { return this.entitlementObs.get(); } + get sentiment(): IChatSentiment { return this.sentimentObs.get(); } + get anonymous(): boolean { return this.anonymousObs.get(); } + + update(): Promise { return Promise.resolve(); } + markAnonymousRateLimited(): void { } +} + +suite('SessionsWelcomeContribution', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockEntitlementService: MockChatEntitlementService; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, disposables); + mockEntitlementService = new MockChatEntitlementService(); + instantiationService.stub(IChatEntitlementService, mockEntitlementService as unknown as IChatEntitlementService); + + // Ensure product has a defaultChatAgent so the contribution activates + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { ...productService.defaultChatAgent, chatExtensionId: 'test.chat' } + } as IProductService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function markReturningUser(): void { + const storageService = instantiationService.get(IStorageService); + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + function isOverlayVisible(): boolean { + const contextKeyService = instantiationService.get(IContextKeyService); + return SessionsWelcomeVisibleContext.getValue(contextKeyService) === true; + } + + test('first launch shows overlay', () => { + // First launch with no entitlement — should show overlay + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + }); + + test('returning user with valid entitlement does not show overlay', () => { + markReturningUser(); + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('returning user: transient Unknown entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate transient Unknown (stale token → 401) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for transient Unknown'); + + // Simulate recovery (token refreshed → entitlement restored) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should remain hidden after recovery'); + }); + + test('returning user: transient Unresolved entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate Unresolved (intermediate state during account resolution) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unresolved, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for Unresolved'); + }); + + test('returning user: extension uninstalled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate extension being uninstalled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is uninstalled'); + }); + + test('returning user: extension disabled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate extension being disabled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: true, disabled: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is disabled'); + }); + + test('overlay dismisses when setup completes', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true, 'should show on first launch'); + + // Simulate completing setup + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should dismiss after setup completes'); + }); + + test('returning user: entitlement going to Available DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Available means user can sign up for free — this is a real state, + // not transient, so the overlay should show + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Available, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay for Available entitlement'); + }); +}); diff --git a/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts b/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts new file mode 100644 index 0000000000000..4351598efa6b2 --- /dev/null +++ b/src/vs/sessions/contrib/workspace/browser/workspace.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { WorkspaceFolderManagementContribution } from './workspaceFolderManagement.js'; + +registerWorkbenchContribution2(WorkspaceFolderManagementContribution.ID, WorkspaceFolderManagementContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts new file mode 100644 index 0000000000000..d0918fa9729c1 --- /dev/null +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { URI } from '../../../../base/common/uri.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; +import { getGitHubRemoteFileDisplayName } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { Queue } from '../../../../base/common/async.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { ISessionData } from '../../sessions/common/sessionData.js'; + +export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.workspaceFolderManagement'; + private queue = this._register(new Queue()); + + constructor( + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + ) { + super(); + this._register(autorun(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + this.queue.queue(() => this.updateWorkspaceFoldersForSession(activeSession)); + })); + } + + private async updateWorkspaceFoldersForSession(session: ISessionData | undefined): Promise { + await this.manageTrustWorkspaceForSession(session); + const activeSessionFolderData = this.getActiveSessionFolderData(session); + const currentRepo = this.workspaceContextService.getWorkspace().folders[0]?.uri; + + if (!activeSessionFolderData) { + if (currentRepo) { + await this.workspaceEditingService.removeFolders([currentRepo], true); + } + return; + } + + if (!currentRepo) { + await this.workspaceEditingService.addFolders([activeSessionFolderData], true); + return; + } + + if (this.uriIdentityService.extUri.isEqual(currentRepo, activeSessionFolderData.uri)) { + return; + } + + await this.workspaceEditingService.updateFolders(0, 1, [activeSessionFolderData], true); + } + + private getActiveSessionFolderData(session: ISessionData | undefined): IWorkspaceFolderCreationData | undefined { + if (!session) { + return undefined; + } + + const workspace = session.workspace.get(); + const repo = workspace?.repositories[0]; + const repository = repo?.uri; + const worktree = repo?.workingDirectory; + const branchName = repo?.detail; + + if (worktree) { + return { + uri: worktree, + name: repository ? `${this.uriIdentityService.extUri.basename(repository)} (${branchName ?? this.uriIdentityService.extUri.basename(worktree)})` : this.uriIdentityService.extUri.basename(worktree) + }; + } + + if (repository) { + // Remote agent host sessions use a read-only FS provider that + // should not be added as a workspace folder. + if (repository.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + + if (session.sessionType === AgentSessionProviders.Background) { + return { uri: repository }; + } + if (session.sessionType === AgentSessionProviders.Cloud) { + return { + uri: repository, + name: getGitHubRemoteFileDisplayName(repository), + }; + } + } + + return undefined; + } + + private async manageTrustWorkspaceForSession(session: ISessionData | undefined): Promise { + if (session?.sessionType !== AgentSessionProviders.Background) { + return; + } + + const workspace = session.workspace.get(); + const repo = workspace?.repositories[0]; + const repository = repo?.uri; + const worktree = repo?.workingDirectory; + + if (!repository || !worktree) { + return; + } + + if (!this.isUriTrusted(worktree)) { + await this.workspaceTrustManagementService.setUrisTrust([worktree], true); + } + } + + private isUriTrusted(uri: URI): boolean { + return this.workspaceTrustManagementService.getTrustedUris().some(trustedUri => this.uriIdentityService.extUri.isEqual(trustedUri, uri)); + } +} diff --git a/src/vs/sessions/copilot-customizations-spec.md b/src/vs/sessions/copilot-customizations-spec.md new file mode 100644 index 0000000000000..2b39296618014 --- /dev/null +++ b/src/vs/sessions/copilot-customizations-spec.md @@ -0,0 +1,356 @@ +# Copilot Agent Runtime — Customization Surface Spec + +> **Purpose:** Definitive reference for every customization mechanism that affects agent behavior when a user sends a message. Intended for building a UI that collects all customizations into a single view. +> +> **Source:** `github/copilot-agent-runtime` codebase as of 2026-02-25. + +> Some information has been removed by the human compiling this spec, scoping to what is deemed most relevant for the sessions window implementation. For the full details, see the source code (for maintainers likely checked out side-by-side). + +--- + +## Overview + +When a user sends a message, the agent assembles its behavior from **10 customization categories**, each discovered from well-known file paths, environment variables, or runtime APIs. This document enumerates every source, file pattern, and merge rule. + +--- + +## 1. Instructions + +System-prompt additions that shape how the agent responds. Multiple sources are discovered and merged in priority order. + +### 1.1 Repo-Level Instruction Files + +Each pattern is defined in `src/helpers/repo-helpers.ts` → `instructionPatterns`: + +| Convention | File Pattern | Notes | +|------------|-------------|-------| +| Copilot | `{repo}/.github/copilot-instructions.md` | Primary repo instructions | +| Codex / OpenAI | `{repo}/AGENTS.md` | OpenAI model convention | +| Claude / Anthropic | `{repo}/CLAUDE.md` | Claude model convention | +| Claude (alt) | `{repo}/.claude/CLAUDE.md` | Secondary Claude location | +| Gemini / Google | `{repo}/GEMINI.md` | Gemini model convention | + +### 1.2 VSCode-Style Instruction Files + +Glob-matched instruction files with metadata (applyTo patterns, description). + +| Scope | File Pattern | Code Reference | +|-------|-------------|----------------| +| Repo | `{repo}/.github/instructions/**/*.instructions.md` | `readVSCodeInstructions()` | +| User | `~/.copilot/instructions/**/*.instructions.md` | `readUserCopilotInstructions()` | + +### 1.3 User-Level Instructions + +| Scope | File Pattern | Code Reference | +|-------|-------------|----------------| +| User global | `~/.copilot/copilot-instructions.md` | `hasHomeCopilotInstructions()` | + +### 1.4 CWD-Specific Instructions + +When the working directory differs from the repo root, the same instruction patterns are re-checked relative to `{cwd}`: + +- `{cwd}/.github/copilot-instructions.md` +- `{cwd}/CLAUDE.md`, `{cwd}/.claude/CLAUDE.md` +- `{cwd}/AGENTS.md` +- `{cwd}/GEMINI.md` + +### 1.5 Nested / Child Instructions + +Breadth-first traversal from `{cwd}` up to **2 levels deep** (`CHILD_INSTRUCTIONS_MAX_DEPTH = 2`), scanning all instruction patterns in subdirectories. + +**Ignored directories:** `node_modules`, `.git`, `vendor`, `dist`, `build`, `.next`, `.nuxt`, `out`, `coverage` (plus `.gitignore` patterns when available). + +Feature-gated via `enableChildInstructions` option. + +### 1.6 Additional Sources + +| Source | Mechanism | +|--------|-----------| +| Env var | `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` — comma-separated list of additional directories to scan | +| Organization | `RuntimeContext.organizationCustomInstructions` — injected at runtime via API (not file-based) | + +### 1.7 Merge Order + +Instructions are concatenated in this order (all additive): + +1. User global (`~/.copilot/copilot-instructions.md`) +2. Repo-level instruction files (all patterns above) +3. VSCode-style instruction files (repo, then user) +4. CWD-specific overrides (when cwd ≠ repo root) +5. Child/nested instructions +6. Organization instructions (API-injected) + +Duplicate content is deduplicated by file content hash. + +--- + +## 2. Skills + +Reusable prompt-based capabilities exposed as `/skill-name` slash commands. + +### 2.1 Discovery Paths + +| Source | File Pattern | Code Reference | +|--------|-------------|----------------| +| Repo (Copilot) | `{repo}/.github/skills/*/SKILL.md` | `loader.ts` collectProjectDirs | +| Repo (Agents) | `{repo}/.agents/skills/*/SKILL.md` | `loader.ts` collectProjectDirs | +| Repo (Claude) | `{repo}/.claude/skills/*/SKILL.md` | `loader.ts` collectProjectDirs | +| User (Copilot) | `~/.copilot/skills/*/SKILL.md` | `loader.ts` personalDirs | +| User (Claude) | `~/.claude/skills/*/SKILL.md` | `loader.ts` personalDirs | +| Env var | Dirs listed in `COPILOT_SKILLS_DIRS` (comma-separated) | `loader.ts` | +| Plugins | `{pluginRoot}/skills/*/SKILL.md` | `skills.ts` | + +### 2.2 File Structure + +Each skill is a directory containing a `SKILL.md` file with YAML frontmatter: + +``` +.github/skills/ + my-skill/ + SKILL.md ← markdown with frontmatter +``` + +Or a flat `SKILL.md` directly in the skills directory (single-skill mode). + +### 2.3 Frontmatter Schema + +```yaml +--- +name: skill-name # Optional; derived from folder name if absent +description: "What this skill does" # Optional; derived from first 3 lines of body +allowed-tools: grep,view # Comma-separated tool whitelist (optional) +user-invocable: true # Whether user can invoke via slash command (default: true) +disable-model-invocation: false # Whether model can invoke autonomously (default: false) +--- + +Skill prompt content here... +``` + +--- + +## 3. Commands + +A variant of skills, loaded from `.claude/commands/` only. + +| Source | File Pattern | Code Reference | +|--------|-------------|----------------| +| Project | `{repo}/.claude/commands/*.md` | `loader.ts` getCommandDirectories | +| User | `~/.claude/commands/*.md` | `loader.ts` getCommandDirectories | + +**Note:** Commands use only the `.claude/` convention — not `.github/` or `.agents/`. + +Any `.md` file in the directory is treated as a command. Same frontmatter schema as skills. Treated internally as skills with `isCommand: true`. Skills take priority over commands on name conflicts. + +--- + +## 4. Custom Agents + +Sub-agent definitions available via the task tool or direct user selection. + +### 4.1 Discovery Paths + +| Source | File Pattern | Code Reference | +|--------|-------------|----------------| +| Repo (Copilot) | `{repo}/.github/agents/*.md`, `*.agent.md` | `useCustomAgents.ts` | +| Repo (Claude) | `{repo}/.claude/agents/*.md`, `*.agent.md` | `useCustomAgents.ts` | +| User (Copilot) | `~/.github/agents/*.md`, `*.agent.md` | `useCustomAgents.ts` | +| User (Claude) | `~/.claude/agents/*.md`, `*.agent.md` | `useCustomAgents.ts` | +| Plugins | `{pluginRoot}/agents/*.md`, `*.agent.md` | `agent-loader.ts` | +| Builtin | `src/agents/definitions/*.agent.yaml` | YAML agent loader | + +### 4.2 Priority Rules + +- `*.agent.md` takes precedence over `*.md` when both exist for the same base name. +- `.github/agents/` sources have higher priority than `.claude/agents/`. + +### 4.3 Frontmatter Schema + +```yaml +--- +name: agent-name +displayName: "Human-Readable Name" +description: "What this agent does" +tools: ["*"] # or ["tool1", "tool2"] — required +model: claude-sonnet-4-20250514 # Optional model override +disableModelInvocation: false # Cannot be auto-invoked as a tool +userInvocable: true # User can select it +mcp-servers: # Inline MCP server config (optional) + server-name: + command: "npx" + args: ["@some/mcp-server"] +--- + +Agent system prompt content here... +Supports {{cwd}} placeholder. +``` + +--- + +## 5. MCP Servers + +Model Context Protocol servers that expose additional tools and resources. + +### 5.1 Config Sources (merge order, last wins) + +| Priority | Source | File Pattern | Code Reference | +|----------|--------|-------------|----------------| +| 1 (lowest) | User | `~/.copilot/mcp-config.json` | `mcp-config.ts` | +| 2 | Workspace | `{cwd}/.mcp.json` | `mcpConfigMerger.ts` | +| 3 | VSCode | `{cwd}/.vscode/mcp.json` | `vsCodeWorkspaceMcpConfig.ts` | +| 4 | Plugins | `{pluginRoot}/.mcp.json`, `{pluginRoot}/.github/mcp.json` | `mcp-loader.ts` | +| 5 | Windows ODR | Registry `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Mcp` | `odrMcpRegistry.ts` | +| 6 (highest) | CLI flag | `--additional-mcp-config ` | `mcpConfigMerger.ts` | + +### 5.2 Config Schema + +```json +{ + "mcpServers": { + "server-name": { + "type": "local | http | sse", + "command": "path/to/server", + "args": ["--flag"], + "cwd": "/optional/working/dir", + "env": { + "KEY": "$ENV_VAR", + "URL": "https://${HOST}:${PORT}", + "WITH_DEFAULT": "${VAR:-fallback}" + }, + "url": "https://remote-server/endpoint", + "headers": { "Authorization": "Bearer ${TOKEN}" }, + "tools": ["*"], + "timeout": 30000, + "filterMapping": "hidden_characters | markdown | none", + "displayName": "My Server", + "oauthClientId": "client-id", + "oauthPublicClient": false + } + } +} +``` + +**Environment variable expansion:** `$VAR`, `${VAR}`, `${VAR:-default}` are all supported in `env`, `args`, `url`, and `headers` fields. + +--- + +## 6. Hooks + +Scripts that execute at specific agent lifecycle events, with the ability to approve/deny/modify behavior. + +### 6.1 Config Sources + +| Source | File Pattern | Code Reference | +|--------|-------------|----------------| +| Config dirs | `{configDir}/**/*.json` | `hookConfigLoader.ts` | +| Plugins | `{pluginRoot}/hooks.json` | `hooks.ts` | +| Plugins (alt) | `{pluginRoot}/hooks/hooks.json` | `hooks.ts` | +| Plugin manifest | Inline in `plugin.json` → `hooks` field (object) | `hooks.ts` | + +### 6.2 Hook Events + +| Event | Trigger | Can Modify? | +|-------|---------|-------------| +| `sessionStart` | Session begins | No (informational) | +| `sessionEnd` | Session ends | No (informational) | +| `userPromptSubmitted` | User sends a message | Yes (can modify prompt) | +| `preToolUse` | Before tool execution | Yes (allow / deny / modify args) | +| `postToolUse` | After tool execution | Yes (can modify result) | +| `errorOccurred` | Error happens | Yes (retry / skip / abort) | +| `agentStop` | Main agent finishes | Yes (can force continuation) | +| `subagentStop` | Sub-agent completes | Yes (can force continuation) | + +### 6.3 Config Schema + +```json +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "command": "bash", + "args": ["-c", "echo checking"], + "cwd": "/optional/cwd", + "env": { "KEY": "value" }, + "timeout": 30000 + } + ] + } +} +``` + +--- + +## 7. Plugins + +Bundles that install combinations of skills, agents, hooks, and MCP servers. + +### 7.1 Plugin Manifest Locations + +Within a plugin repository, the manifest is searched at: + +| File Pattern | Code Reference | +|-------------|----------------| +| `plugin.json` | `marketplace-loader.ts` PLUGIN_JSON_PATHS | +| `.github/plugin/plugin.json` | `marketplace-loader.ts` PLUGIN_JSON_PATHS | +| `.claude-plugin/plugin.json` | `marketplace-loader.ts` PLUGIN_JSON_PATHS | + + +Each field (`skills`, `agents`, `hooks`) can be a string path, array of paths, or (for hooks) an inline object. + +--- + +## Appendix A: XDG Base Directory Compliance + +All `~/.copilot/` paths respect XDG overrides: + +| Type | Default | XDG Override | +|------|---------|-------------| +| Config files | `~/.copilot/` | `$XDG_CONFIG_HOME/.copilot/` | +| State/cache | `~/.copilot/` | `$XDG_STATE_HOME/.copilot/` | + +The base directory name is always `.copilot` (`APP_DIRECTORY` in `path-helpers.ts`). + +--- + +## Appendix B: Complete Discovery Summary + +``` +Message received + │ + ├─ Feature flags resolved + │ ├─ Tier defaults + │ ├─ config.json → feature_flags.enabled + │ └─ Env vars (COPILOT_CLI_ENABLED_FEATURE_FLAGS, individual) + │ + ├─ System prompt assembled + │ ├─ Base agent prompt + │ ├─ User instructions ~/.copilot/copilot-instructions.md + │ ├─ Repo instructions .github/copilot-instructions.md, AGENTS.md, CLAUDE.md, GEMINI.md + │ ├─ VSCode instructions .github/instructions/**/*.instructions.md + │ ├─ CWD instructions (when cwd ≠ repo root) + │ ├─ Child instructions (depth=2 traversal) + │ └─ Org instructions (API-injected) + │ + ├─ Tools assembled + │ ├─ Built-in tools + │ ├─ MCP servers ~/.copilot/mcp-config.json + .mcp.json + .vscode/mcp.json + plugins + │ └─ Content exclusion (org API restrictions applied) + │ + ├─ Skills listed .github/skills/ + .agents/skills/ + .claude/skills/ + personal + plugins + ├─ Commands listed .claude/commands/ + personal + ├─ Custom agents listed .github/agents/ + .claude/agents/ + personal + plugins + │ + ├─ userPromptSubmitted hooks fire + │ + ├─ Model selected config.json → model, agent override, or default + │ + ├─ For each tool call: + │ ├─ preToolUse hooks (allow / deny / modify) + │ ├─ Permission check + │ ├─ Firewall policy + │ ├─ Tool executes + │ └─ postToolUse hooks (modify result) + │ + └─ Session telemetry emitted +``` diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index b72a879cb7d86..5c2692372ff30 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -10,6 +10,7 @@ import { IContextKeyService } from '../../../platform/contextkey/common/contextk import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; @@ -20,6 +21,7 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; +import { isMacintosh } from '../../../base/common/platform.js'; export class NativeTitlebarPart extends TitlebarPart { @@ -37,6 +39,7 @@ export class NativeTitlebarPart extends TitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService private readonly productService: IProductService, @INativeHostService private readonly nativeHostService: INativeHostService, ) { super(id, targetWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); @@ -44,6 +47,24 @@ export class NativeTitlebarPart extends TitlebarPart { this.handleWindowsAlwaysOnTop(targetWindow.vscodeWindowId, contextKeyService); } + protected override createContentArea(parent: HTMLElement): HTMLElement { + + // Workaround for macOS/Electron bug where the window does not + // appear in the "Windows" menu if the first `document.title` + // matches the BrowserWindow's initial title. + // See: https://github.com/microsoft/vscode/issues/191288 + if (isMacintosh) { + const window = getWindow(this.element); + const nativeTitle = this.productService.nameLong; + if (!window.document.title || window.document.title === nativeTitle) { + window.document.title = `${nativeTitle} \u200b`; + } + window.document.title = nativeTitle; + } + + return super.createContentArea(parent); + } + private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise { const isWindowAlwaysOnTopContext = IsWindowAlwaysOnTopContext.bindTo(contextKeyService); @@ -107,9 +128,10 @@ class MainNativeTitlebarPart extends NativeTitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { - super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } } @@ -130,10 +152,11 @@ class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements IAuxilia @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } override get preventZoom(): boolean { diff --git a/src/vs/sessions/electron-browser/sessions-dev.html b/src/vs/sessions/electron-browser/sessions-dev.html index 56f1b22575beb..f453fb51b7fe7 100644 --- a/src/vs/sessions/electron-browser/sessions-dev.html +++ b/src/vs/sessions/electron-browser/sessions-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/sessions/electron-browser/sessions.html b/src/vs/sessions/electron-browser/sessions.html index afb0a45e67ec7..de2f45b136e5c 100644 --- a/src/vs/sessions/electron-browser/sessions.html +++ b/src/vs/sessions/electron-browser/sessions.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 87669d73043a2..4cb60de615791 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -11,12 +11,11 @@ import { setFullscreen } from '../../base/browser/browser.js'; import { domContentLoaded } from '../../base/browser/dom.js'; import { onUnexpectedError } from '../../base/common/errors.js'; import { URI } from '../../base/common/uri.js'; -import { WorkspaceService } from '../../workbench/services/configuration/browser/configurationService.js'; import { INativeWorkbenchEnvironmentService, NativeWorkbenchEnvironmentService } from '../../workbench/services/environment/electron-browser/environmentService.js'; import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; import { ILoggerService, ILogService, LogLevel } from '../../platform/log/common/log.js'; import { NativeWorkbenchStorageService } from '../../workbench/services/storage/electron-browser/storageService.js'; -import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier, toWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier } from '../../platform/workspace/common/workspace.js'; import { IWorkbenchConfigurationService } from '../../workbench/services/configuration/common/configuration.js'; import { IStorageService } from '../../platform/storage/common/storage.js'; import { Disposable } from '../../base/common/lifecycle.js'; @@ -30,7 +29,6 @@ import { IRemoteAgentService } from '../../workbench/services/remote/common/remo import { FileService } from '../../platform/files/common/fileService.js'; import { IFileService } from '../../platform/files/common/files.js'; import { RemoteFileSystemProviderClient } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; -import { ConfigurationCache } from '../../workbench/services/configuration/common/configurationCache.js'; import { ISignService } from '../../platform/sign/common/sign.js'; import { IProductService } from '../../platform/product/common/productService.js'; import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; @@ -66,8 +64,12 @@ import { AccountPolicyService } from '../../workbench/services/policies/common/a import { MultiplexPolicyService } from '../../workbench/services/policies/common/multiplexPolicyService.js'; import { Workbench as AgenticWorkbench } from '../browser/workbench.js'; import { NativeMenubarControl } from '../../workbench/electron-browser/parts/titlebar/menubarControl.js'; +import { IWorkspaceEditingService } from '../../workbench/services/workspaces/common/workspaceEditing.js'; +import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; +import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../workbench/services/workspaces/browser/workspaces.js'; -export class AgenticMain extends Disposable { +export class SessionsMain extends Disposable { constructor( private readonly configuration: INativeWindowConfiguration @@ -167,7 +169,7 @@ export class AgenticMain extends Disposable { this._register(workbench.onDidShutdown(() => this.dispose())); } - private async initServices(): Promise<{ serviceCollection: ServiceCollection; logService: ILogService; storageService: NativeWorkbenchStorageService; configurationService: WorkspaceService }> { + private async initServices(): Promise<{ serviceCollection: ServiceCollection; logService: ILogService; storageService: NativeWorkbenchStorageService; configurationService: ConfigurationService }> { const serviceCollection = new ServiceCollection(); @@ -290,21 +292,23 @@ export class AgenticMain extends Disposable { // // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // Create services that require resolving in parallel - const workspace = this.resolveWorkspaceIdentifier(environmentService); - const [configurationService, storageService] = await Promise.all([ - this.createWorkspaceService(workspace, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService).then(service => { + const workspaceIdentifier = getWorkspaceIdentifier(environmentService.agentSessionsWorkspace); + const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService); - // Workspace - serviceCollection.set(IWorkspaceContextService, service); + // Workspace + serviceCollection.set(IWorkspaceContextService, workspaceContextService); + serviceCollection.set(IWorkspaceEditingService, workspaceContextService); + + const [configurationService, storageService] = await Promise.all([ + this.createConfigurationService(workspaceContextService, userDataProfileService, uriIdentityService, fileService, logService, policyService).then(configurationService => { // Configuration - serviceCollection.set(IWorkbenchConfigurationService, service); + serviceCollection.set(IWorkbenchConfigurationService, configurationService); - return service; + return configurationService; }), - this.createStorageService(workspace, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { + this.createStorageService(workspaceIdentifier, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { // Storage serviceCollection.set(IStorageService, service); @@ -325,13 +329,9 @@ export class AgenticMain extends Disposable { const workspaceTrustEnablementService = new WorkspaceTrustEnablementService(configurationService, environmentService); serviceCollection.set(IWorkspaceTrustEnablementService, workspaceTrustEnablementService); - const workspaceTrustManagementService = new WorkspaceTrustManagementService(configurationService, remoteAuthorityResolverService, storageService, uriIdentityService, environmentService, configurationService, workspaceTrustEnablementService, fileService); + const workspaceTrustManagementService = new WorkspaceTrustManagementService(configurationService, remoteAuthorityResolverService, storageService, uriIdentityService, environmentService, workspaceContextService, workspaceTrustEnablementService, fileService); serviceCollection.set(IWorkspaceTrustManagementService, workspaceTrustManagementService); - // Update workspace trust so that configuration is updated accordingly - configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()); - this._register(workspaceTrustManagementService.onDidChangeTrust(() => configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()))); - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // @@ -346,40 +346,22 @@ export class AgenticMain extends Disposable { return { serviceCollection, logService, storageService, configurationService }; } - private resolveWorkspaceIdentifier(environmentService: INativeWorkbenchEnvironmentService): IAnyWorkspaceIdentifier { - - // Return early for when a folder or multi-root is opened - if (this.configuration.workspace) { - return this.configuration.workspace; - } - - // Otherwise, workspace is empty, so we derive an identifier - return toWorkspaceIdentifier(this.configuration.backupPath, environmentService.isExtensionDevelopment); - } - - private async createWorkspaceService( - workspace: IAnyWorkspaceIdentifier, - environmentService: INativeWorkbenchEnvironmentService, + private async createConfigurationService( + workspaceContextService: SessionsWorkspaceContextService, userDataProfileService: IUserDataProfileService, - userDataProfilesService: IUserDataProfilesService, - fileService: FileService, - remoteAgentService: IRemoteAgentService, uriIdentityService: IUriIdentityService, + fileService: FileService, logService: ILogService, policyService: IPolicyService - ): Promise { - const configurationCache = new ConfigurationCache([Schemas.file, Schemas.vscodeUserData] /* Cache all non native resources */, environmentService, fileService); - const workspaceService = new WorkspaceService({ remoteAuthority: environmentService.remoteAuthority, configurationCache }, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService); - + ): Promise { + const configurationService = new ConfigurationService(userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, logService); try { - await workspaceService.initialize(workspace); - - return workspaceService; + await configurationService.initialize(); } catch (error) { onUnexpectedError(error); - - return workspaceService; } + + return configurationService; } private async createStorageService(workspace: IAnyWorkspaceIdentifier, environmentService: INativeWorkbenchEnvironmentService, userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService): Promise { @@ -416,7 +398,7 @@ export interface IDesktopMain { } export function main(configuration: INativeWindowConfiguration): Promise { - const workbench = new AgenticMain(configuration); + const workbench = new SessionsMain(configuration); return workbench.open(); } diff --git a/src/vs/sessions/prompts/act-on-feedback.prompt.md b/src/vs/sessions/prompts/act-on-feedback.prompt.md new file mode 100644 index 0000000000000..29ef4622505b7 --- /dev/null +++ b/src/vs/sessions/prompts/act-on-feedback.prompt.md @@ -0,0 +1,11 @@ +--- +description: Act on user feedback attached to the current session +--- + + +The user has provided feedback on the current session's changes. Their feedback comments have been attached to this message. + +1. Review all attached feedback comments carefully +2. Understand the intent behind each piece of feedback +3. Make the requested changes to address the feedback +4. Verify your changes are consistent with the rest of the codebase diff --git a/src/vs/sessions/prompts/create-draft-pr.prompt.md b/src/vs/sessions/prompts/create-draft-pr.prompt.md new file mode 100644 index 0000000000000..c2529a264d4aa --- /dev/null +++ b/src/vs/sessions/prompts/create-draft-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Create a draft pull request for the current session +--- + + +Use the GitHub MCP server to create a draft pull request — do NOT use the `gh` CLI. + +1. Run the compile and hygiene tasks (fixing any errors) +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the draft pull request diff --git a/src/vs/sessions/prompts/create-pr.prompt.md b/src/vs/sessions/prompts/create-pr.prompt.md new file mode 100644 index 0000000000000..4991f4ff58216 --- /dev/null +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Create a pull request for the current session +--- + + +Use the GitHub MCP server to create a pull request — do NOT use the `gh` CLI. + +1. Run the compile and hygiene tasks (fixing any errors) +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the pull request diff --git a/src/vs/sessions/prompts/generate-run-commands.prompt.md b/src/vs/sessions/prompts/generate-run-commands.prompt.md new file mode 100644 index 0000000000000..e1744cf92c38a --- /dev/null +++ b/src/vs/sessions/prompts/generate-run-commands.prompt.md @@ -0,0 +1,50 @@ +--- +description: Generate or modify run commands for the current session +--- + + +Help the user set up run commands for the current Agent Session workspace. Run commands appear in the session's Run button in the title bar. + +## Understanding the task schema + +A run command is a `tasks.json` task with: +- `"inSessions": true` — required: makes the task appear in the Sessions run button +- `"runOptions": { "runOn": "worktreeCreated" }` — optional: auto-runs the task whenever a new worktree is created (use for setup/install commands) + +```json +{ + "tasks": [ + { + "label": "Install dependencies", + "type": "shell", + "command": "npm install", + "inSessions": true, + "runOptions": { "runOn": "worktreeCreated" } + }, + { + "label": "Start dev server", + "type": "shell", + "command": "npm run dev", + "inSessions": true + } + ] +} +``` + +## Decision logic + +**First, read the existing `.vscode/tasks.json`** to check for existing run commands (`inSessions: true` tasks). + +**If run commands already exist:** treat this as a modify request — ask the user what they'd like to change (add, remove, or update a command). + +**If no run commands exist:** try to infer the right commands from the workspace: +- Check `package.json`, `Makefile`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `.nvmrc`, or other project files to understand the stack and common commands. +- If it's clear what the setup command is (e.g., `npm install`, `pip install -r requirements.txt`), add it with `"runOptions": { "runOn": "worktreeCreated" }` — no need to ask. +- If it's clear what the primary run/dev command is (e.g., `npm run dev`, `cargo run`), add it with just `"inSessions": true`. +- **Only ask the user** if the commands are ambiguous (e.g., multiple equally valid options, no recognizable project structure, or the project uses a non-standard setup). + +## Writing the file + +Always write to `.vscode/tasks.json` in the workspace root. If the file already exists, merge — do not overwrite unrelated tasks. + +After writing, briefly confirm what was added and how to trigger it from the Run button. diff --git a/src/vs/sessions/prompts/merge-changes.prompt.md b/src/vs/sessions/prompts/merge-changes.prompt.md new file mode 100644 index 0000000000000..065cb18ad18eb --- /dev/null +++ b/src/vs/sessions/prompts/merge-changes.prompt.md @@ -0,0 +1,10 @@ +--- +description: Merge changes from the topic branch to the merge base branch +--- + + +Merge changes from the topic branch to the merge base branch. +The context block appended to the prompt contains the source and target branch information. + +1. If there are any uncommitted changes, use the `/commit` skill to commit them +2. Merge the topic branch into the merge base branch. If there are any merge conflicts, resolve them and commit the merge. When in doubt on how to resolve a merge conflict, ask the user for guidance on how to proceed diff --git a/src/vs/sessions/prompts/update-pr.prompt.md b/src/vs/sessions/prompts/update-pr.prompt.md new file mode 100644 index 0000000000000..22ecf6ccf52e0 --- /dev/null +++ b/src/vs/sessions/prompts/update-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Update the pull request for the current session +--- + + +Update the existing pull request for the current session. +The context block appended to the prompt contains the pull request information. + +1. Check whether the pull request has any commits that are not yet present on the current branch (incoming changes). If there are any incoming changes, pull them into the current branch and resolve any merge conflicts +2. Run the compile and hygiene tasks (fixing any errors) +3. If there are any uncommitted changes, use the `/commit` skill to commit them +4. If the outgoing changes introduce significant changes to the pull request, update the pull request title and description to reflect those changes +5. Update the pull request with the new commits and information diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts new file mode 100644 index 0000000000000..01fb00152a6a1 --- /dev/null +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -0,0 +1,377 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Queue } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { JSONPath, ParseError, parse } from '../../../../base/common/json.js'; +import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; +import { Edit, FormattingOptions } from '../../../../base/common/jsonFormatter.js'; +import { equals } from '../../../../base/common/objects.js'; +import { distinct, equals as arrayEquals } from '../../../../base/common/arrays.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; +import { Extensions, IConfigurationRegistry, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IPolicyService, NullPolicyService } from '../../../../platform/policy/common/policy.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFolder, WorkbenchState, Workspace } from '../../../../platform/workspace/common/workspace.js'; +import { FolderConfiguration, UserConfiguration } from '../../../../workbench/services/configuration/browser/configuration.js'; +import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, FOLDER_CONFIG_FOLDER_NAME, FOLDER_SETTINGS_PATH, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { Configuration } from '../../../../workbench/services/configuration/common/configurationModels.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; + +// Import to register configuration contributions +import '../../../../workbench/services/configuration/browser/configurationService.js'; + +export class ConfigurationService extends Disposable implements IWorkbenchConfigurationService { + + declare readonly _serviceBrand: undefined; + + private _configuration: Configuration; + private readonly defaultConfiguration: DefaultConfiguration; + private readonly policyConfiguration: IPolicyConfiguration; + private readonly userConfiguration: UserConfiguration; + private readonly cachedFolderConfigs = this._register(new DisposableMap(new ResourceMap())); + + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; + + readonly onDidChangeRestrictedSettings = Event.None; + readonly restrictedSettings: RestrictedSettings = { default: [] }; + + private readonly configurationRegistry = Registry.as(Extensions.Configuration); + + private readonly settingsResource: URI; + private readonly configurationEditing: ConfigurationEditing; + + constructor( + userDataProfileService: IUserDataProfileService, + private readonly workspaceService: IWorkspaceContextService, + private readonly uriIdentityService: IUriIdentityService, + private readonly fileService: IFileService, + policyService: IPolicyService, + private readonly logService: ILogService, + ) { + super(); + + this.settingsResource = userDataProfileService.currentProfile.settingsResource; + this.defaultConfiguration = this._register(new DefaultConfiguration(logService)); + this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); + this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, {}, fileService, uriIdentityService, logService)); + this.configurationEditing = new ConfigurationEditing(fileService, this); + + this._configuration = new Configuration( + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + this.workspaceService.getWorkspace() as Workspace, + this.logService + ); + + this._register(this.defaultConfiguration.onDidChangeConfiguration(({ defaults, properties }) => this.onDefaultConfigurationChanged(defaults, properties))); + this._register(this.policyConfiguration.onDidChangeConfiguration(configurationModel => this.onPolicyConfigurationChanged(configurationModel))); + this._register(this.userConfiguration.onDidChangeConfiguration(userConfiguration => this.onUserConfigurationChanged(userConfiguration))); + this._register(this.workspaceService.onWillChangeWorkspaceFolders(e => e.join(this.loadFolderConfigurations(e.changes.added)))); + this._register(this.workspaceService.onDidChangeWorkspaceFolders(e => this.onWorkspaceFoldersChanged(e))); + } + + async initialize(): Promise { + const [defaultModel, policyModel, userModel] = await Promise.all([ + this.defaultConfiguration.initialize(), + this.policyConfiguration.initialize(), + this.userConfiguration.initialize() + ]); + const workspace = this.workspaceService.getWorkspace() as Workspace; + this._configuration = new Configuration( + defaultModel, + policyModel, + ConfigurationModel.createEmptyModel(this.logService), + userModel, + ConfigurationModel.createEmptyModel(this.logService), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + workspace, + this.logService + ); + await this.loadFolderConfigurations(workspace.folders); + } + + // #region IWorkbenchConfigurationService + + getConfigurationData(): IConfigurationData { + return this._configuration.toData(); + } + + getValue(): T; + getValue(section: string): T; + getValue(overrides: IConfigurationOverrides): T; + getValue(section: string, overrides: IConfigurationOverrides): T; + getValue(arg1?: unknown, arg2?: unknown): unknown { + const section = typeof arg1 === 'string' ? arg1 : undefined; + const overrides = isConfigurationOverrides(arg1) ? arg1 : isConfigurationOverrides(arg2) ? arg2 : undefined; + return this._configuration.getValue(section, overrides); + } + + updateValue(key: string, value: unknown): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides): Promise; + updateValue(key: string, value: unknown, target: ConfigurationTarget): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides, target: ConfigurationTarget, options?: IConfigurationUpdateOptions): Promise; + async updateValue(key: string, value: unknown, arg3?: unknown, arg4?: unknown, _options?: IConfigurationUpdateOptions): Promise { + const overrides: IConfigurationUpdateOverrides | undefined = isConfigurationUpdateOverrides(arg3) ? arg3 + : isConfigurationOverrides(arg3) ? { resource: arg3.resource, overrideIdentifiers: arg3.overrideIdentifier ? [arg3.overrideIdentifier] : undefined } : undefined; + const target: ConfigurationTarget | undefined = (overrides ? arg4 : arg3) as ConfigurationTarget | undefined; + + if (overrides?.overrideIdentifiers) { + overrides.overrideIdentifiers = distinct(overrides.overrideIdentifiers); + overrides.overrideIdentifiers = overrides.overrideIdentifiers.length ? overrides.overrideIdentifiers : undefined; + } + + const inspect = this.inspect(key, { resource: overrides?.resource, overrideIdentifier: overrides?.overrideIdentifiers ? overrides.overrideIdentifiers[0] : undefined }); + if (inspect.policyValue !== undefined) { + throw new Error(`Unable to write ${key} because it is configured in system policy.`); + } + + // Remove the setting, if the value is same as default value + if (equals(value, inspect.defaultValue)) { + value = undefined; + } + + if (overrides?.overrideIdentifiers?.length && overrides.overrideIdentifiers.length > 1) { + const overrideIdentifiers = overrides.overrideIdentifiers.sort(); + const existingOverrides = this._configuration.localUserConfiguration.overrides.find(override => arrayEquals([...override.identifiers].sort(), overrideIdentifiers)); + if (existingOverrides) { + overrides.overrideIdentifiers = existingOverrides.identifiers; + } + } + + const path = overrides?.overrideIdentifiers?.length ? [keyFromOverrideIdentifiers(overrides.overrideIdentifiers), key] : [key]; + + const settingsResource = this.getSettingsResource(target, overrides?.resource ?? undefined); + await this.configurationEditing.write(settingsResource, path, value); + await this.reloadConfiguration(); + } + + private getSettingsResource(target: ConfigurationTarget | undefined, resource: URI | undefined): URI { + if (target === ConfigurationTarget.WORKSPACE_FOLDER || target === ConfigurationTarget.WORKSPACE) { + if (resource) { + const folder = this.workspaceService.getWorkspaceFolder(resource); + if (folder) { + return this.uriIdentityService.extUri.joinPath(folder.uri, FOLDER_SETTINGS_PATH); + } + } + } + return this.settingsResource; + } + + inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + return this._configuration.inspect(key, overrides); + } + + keys(): { default: string[]; policy: string[]; user: string[]; workspace: string[]; workspaceFolder: string[] } { + return this._configuration.keys(); + } + + async reloadConfiguration(_target?: ConfigurationTarget | IWorkspaceFolder): Promise { + const userModel = await this.userConfiguration.initialize(); + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userModel); + + // Reload folder configurations + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + const folderModel = await folderConfiguration.loadConfiguration(); + const folderChange = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, folderModel); + change.keys.push(...folderChange.keys); + change.overrides.push(...folderChange.overrides); + } + } + + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + hasCachedConfigurationDefaultsOverrides(): boolean { + return false; + } + + async whenRemoteConfigurationLoaded(): Promise { } + + isSettingAppliedForAllProfiles(key: string): boolean { + const scope = this.configurationRegistry.getConfigurationProperties()[key]?.scope; + if (scope && APPLICATION_SCOPES.includes(scope)) { + return true; + } + const allProfilesSettings = this.getValue(APPLY_ALL_PROFILES_SETTING) ?? []; + return Array.isArray(allProfilesSettings) && allProfilesSettings.includes(key); + } + + // #endregion + + // #region Configuration change handlers + + private onDefaultConfigurationChanged(defaults: ConfigurationModel, properties?: string[]): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateDefaultConfiguration(defaults, properties); + this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse()); + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + this._configuration.updateFolderConfiguration(folder.uri, folderConfiguration.reparse()); + } + } + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onPolicyConfigurationChanged(policyConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdatePolicyConfiguration(policyConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onUserConfigurationChanged(userConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + private onWorkspaceFoldersChanged(e: IWorkspaceFoldersChangeEvent): void { + // Remove configurations for removed folders + const previousData = this._configuration.toData(); + const keys: string[] = []; + const overrides: [string, string[]][] = []; + for (const folder of e.removed) { + const change = this._configuration.compareAndDeleteFolderConfiguration(folder.uri); + keys.push(...change.keys); + overrides.push(...change.overrides); + this.cachedFolderConfigs.deleteAndDispose(folder.uri); + } + if (keys.length || overrides.length) { + this.triggerConfigurationChange({ keys, overrides }, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + } + } + + private onWorkspaceFolderConfigurationChanged(folder: IWorkspaceFolder): void { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + folderConfiguration.loadConfiguration().then(configurationModel => { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, configurationModel); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + }, onUnexpectedError); + } + } + + private async loadFolderConfigurations(folders: readonly IWorkspaceFolder[]): Promise { + for (const folder of folders) { + let folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (!folderConfiguration) { + folderConfiguration = new FolderConfiguration(false, folder, FOLDER_CONFIG_FOLDER_NAME, WorkbenchState.WORKSPACE, true, this.fileService, this.uriIdentityService, this.logService, { needsCaching: () => false, read: async () => '', write: async () => { }, remove: async () => { } }); + folderConfiguration.addRelated(folderConfiguration.onDidChange(() => this.onWorkspaceFolderConfigurationChanged(folder))); + this.cachedFolderConfigs.set(folder.uri, folderConfiguration); + } + const configurationModel = await folderConfiguration.loadConfiguration(); + this._configuration.updateFolderConfiguration(folder.uri, configurationModel); + } + } + + private triggerConfigurationChange(change: IConfigurationChange, previousData: IConfigurationData, target: ConfigurationTarget): void { + if (change.keys.length) { + const workspace = this.workspaceService.getWorkspace() as Workspace; + const event = new ConfigurationChangeEvent(change, { data: previousData, workspace }, this._configuration, workspace, this.logService); + event.source = target; + this._onDidChangeConfiguration.fire(event); + } + } + + // #endregion +} + +class ConfigurationEditing { + + private readonly queue = new Queue(); + + constructor( + private readonly fileService: IFileService, + private readonly configurationService: ConfigurationService, + ) { } + + write(settingsResource: URI, path: JSONPath, value: unknown): Promise { + return this.queue.queue(() => this.doWriteConfiguration(settingsResource, path, value)); + } + + private async doWriteConfiguration(settingsResource: URI, path: JSONPath, value: unknown): Promise { + let content: string; + try { + const fileContent = await this.fileService.readFile(settingsResource); + content = fileContent.value.toString(); + } catch (error) { + if ((error as FileOperationError).fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + content = '{}'; + } else { + throw error; + } + } + + const parseErrors: ParseError[] = []; + parse(content, parseErrors, { allowTrailingComma: true, allowEmptyContent: true }); + if (parseErrors.length > 0) { + throw new Error('Unable to write into the settings file. Please open the file to correct errors/warnings in the file and try again.'); + } + + const edits = this.getEdits(content, path, value); + content = applyEdits(content, edits); + + await this.fileService.writeFile(settingsResource, VSBuffer.fromString(content)); + } + + private getEdits(content: string, path: JSONPath, value: unknown): Edit[] { + const { tabSize, insertSpaces, eol } = this.formattingOptions; + + if (!path.length) { + const newContent = JSON.stringify(value, null, insertSpaces ? ' '.repeat(tabSize) : '\t'); + return [{ + content: newContent, + length: content.length, + offset: 0 + }]; + } + + return setProperty(content, path, value, { tabSize, insertSpaces, eol }); + } + + private _formattingOptions: Required | undefined; + private get formattingOptions(): Required { + if (!this._formattingOptions) { + let eol = OS === OperatingSystem.Linux || OS === OperatingSystem.Macintosh ? '\n' : '\r\n'; + const configuredEol = this.configurationService.getValue('files.eol', { overrideIdentifier: 'jsonc' }); + if (configuredEol && typeof configuredEol === 'string' && configuredEol !== 'auto') { + eol = configuredEol; + } + this._formattingOptions = { + eol, + insertSpaces: !!this.configurationService.getValue('editor.insertSpaces', { overrideIdentifier: 'jsonc' }), + tabSize: this.configurationService.getValue('editor.tabSize', { overrideIdentifier: 'jsonc' }) + }; + } + return this._formattingOptions; + } +} diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts new file mode 100644 index 0000000000000..fdfd8a4f1e9f4 --- /dev/null +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullPolicyService } from '../../../../../platform/policy/common/policy.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { UserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; +import { UserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfileService.js'; +import { FileUserDataProvider } from '../../../../../platform/userData/common/fileUserDataProvider.js'; +import { TestEnvironmentService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ConfigurationService } from '../../browser/configurationService.js'; +import { SessionsWorkspaceContextService } from '../../../workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../../../../workbench/services/workspaces/browser/workspaces.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IUserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfile.js'; + +const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); + +suite('Sessions ConfigurationService', () => { + + let testObject: ConfigurationService; + let workspaceService: SessionsWorkspaceContextService; + let fileService: FileService; + let userDataProfileService: IUserDataProfileService; + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suiteSetup(() => { + configurationRegistry.registerConfiguration({ + 'id': '_test_sessions', + 'type': 'object', + 'properties': { + 'sessionsConfigurationService.testSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.RESOURCE + }, + 'sessionsConfigurationService.machineSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.MACHINE + }, + 'sessionsConfigurationService.applicationSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.APPLICATION + }, + } + }); + }); + + setup(async () => { + const logService = new NullLogService(); + fileService = disposables.add(new FileService(logService)); + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(ROOT.scheme, fileSystemProvider)); + + const environmentService = TestEnvironmentService; + const uriIdentityService = disposables.add(new UriIdentityService(fileService)); + const userDataProfilesService = disposables.add(new UserDataProfilesService(environmentService, fileService, uriIdentityService, logService)); + disposables.add(fileService.registerProvider(Schemas.vscodeUserData, disposables.add(new FileUserDataProvider(ROOT.scheme, fileSystemProvider, Schemas.vscodeUserData, userDataProfilesService, uriIdentityService, logService)))); + userDataProfileService = disposables.add(new UserDataProfileService(userDataProfilesService.defaultProfile)); + + const configResource = joinPath(ROOT, 'agent-sessions.code-workspace'); + await fileService.writeFile(configResource, VSBuffer.fromString(JSON.stringify({ folders: [] }))); + + workspaceService = disposables.add(new SessionsWorkspaceContextService(getWorkspaceIdentifier(configResource), uriIdentityService)); + testObject = disposables.add(new ConfigurationService(userDataProfileService, workspaceService, uriIdentityService, fileService, new NullPolicyService(), logService)); + await testObject.initialize(); + }); + + // #region Reading + + test('defaults', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting'), 'defaultValue'); + }); + + test('user settings override defaults', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('workspace folder settings override user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'myFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are read when folders are added', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'addedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are removed when folders are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired when folders with settings are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder2'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await workspaceService.removeFolders([folder]); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired on user settings change', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('inspect returns correct values per layer', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userValue'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderValue'); + })); + + test('application settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'appFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.applicationSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting', { resource: folder }), 'defaultValue'); + })); + + test('machine settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'machineFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.machineSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting', { resource: folder }), 'defaultValue'); + })); + + test('folder settings change fires configuration change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'changeFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "initialValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'initialValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "updatedValue" }')); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'updatedValue'); + })); + + // #endregion + + // #region Writing + + test('updateValue writes to user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'writtenValue'); + })); + + test('updateValue persists to settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedValue'); + + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedValue')); + })); + + test('updateValue fires change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'eventValue'); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('updateValue removes setting when value equals default', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'nonDefault'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'nonDefault'); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'defaultValue'); + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(!content.includes('sessionsConfigurationService.testSetting')); + })); + + test('updateValue can update multiple settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'value1'); + await testObject.updateValue('sessionsConfigurationService.machineSetting', 'value2'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'value1'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'value2'); + })); + + test('updateValue with language override', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'langValue', { overrideIdentifier: 'jsonc' }); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { overrideIdentifier: 'jsonc' }), 'langValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('updateValue is reflected in inspect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'inspectedValue'); + const inspection = testObject.inspect('sessionsConfigurationService.testSetting'); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'inspectedValue'); + })); + + // #endregion + + // #region Workspace Folder - Read and Write + + test('read setting from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'readFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + + await workspaceService.addFolders([{ uri: folder }]); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('write setting to workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'writeFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'writtenFolderValue'); + })); + + test('write setting to workspace folder persists to folder settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'persistFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const content = (await fileService.readFile(joinPath(folder, '.vscode', 'settings.json'))).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedFolderValue')); + })); + + test('write setting to workspace folder does not affect user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'isolateFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderOnly', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderOnly'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('workspace folder setting overrides user setting for resource', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'overrideFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userValue'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('inspect shows workspace folder value after write', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectWriteFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userVal'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderVal', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userVal'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderVal'); + })); + + test('removing folder clears its written settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'clearFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + // #endregion +}); diff --git a/src/vs/sessions/services/title/browser/titleService.ts b/src/vs/sessions/services/title/browser/titleService.ts new file mode 100644 index 0000000000000..b04868f061e1f --- /dev/null +++ b/src/vs/sessions/services/title/browser/titleService.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ITitleService } from '../../../../workbench/services/title/browser/titleService.js'; +import { TitleService } from '../../../browser/parts/titlebarPart.js'; + +registerSingleton(ITitleService, TitleService, InstantiationType.Eager); diff --git a/src/vs/sessions/electron-browser/titleService.ts b/src/vs/sessions/services/title/electron-browser/titleService.ts similarity index 59% rename from src/vs/sessions/electron-browser/titleService.ts rename to src/vs/sessions/services/title/electron-browser/titleService.ts index 9ffc7a93650af..070b1eb7bc4fc 100644 --- a/src/vs/sessions/electron-browser/titleService.ts +++ b/src/vs/sessions/services/title/electron-browser/titleService.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; -import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; -import { NativeTitleService } from './parts/titlebarPart.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ITitleService } from '../../../../workbench/services/title/browser/titleService.js'; +import { NativeTitleService } from '../../../electron-browser/parts/titlebarPart.js'; registerSingleton(ITitleService, NativeTitleService, InstantiationType.Eager); diff --git a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts new file mode 100644 index 0000000000000..0e2aab0e8fcb0 --- /dev/null +++ b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Queue } from '../../../../base/common/async.js'; +import { removeTrailingPathSeparator } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { Workspace, WorkspaceFolder, IWorkspace, IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; +import { getWorkspaceIdentifier } from '../../../../workbench/services/workspaces/browser/workspaces.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +export class SessionsWorkspaceContextService extends Disposable implements IWorkspaceContextService, IWorkspaceEditingService { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeWorkbenchState = Event.None; + readonly onDidChangeWorkspaceName = Event.None; + readonly onDidEnterWorkspace = Event.None; + + private readonly _onWillChangeWorkspaceFolders = new Emitter(); + readonly onWillChangeWorkspaceFolders = this._onWillChangeWorkspaceFolders.event; + + private readonly _onDidChangeWorkspaceFolders = this._register(new Emitter()); + readonly onDidChangeWorkspaceFolders = this._onDidChangeWorkspaceFolders.event; + + private workspace: Workspace; + private readonly _updateFoldersQueue = this._register(new Queue()); + + constructor( + workspaceIdentifier: IWorkspaceIdentifier, + private readonly uriIdentityService: IUriIdentityService, + ) { + super(); + this.workspace = new Workspace(workspaceIdentifier.id, [], false, workspaceIdentifier.configPath, uri => uriIdentityService.extUri.ignorePathCasing(uri)); + } + + getCompleteWorkspace(): Promise { + return Promise.resolve(this.workspace); + } + + getWorkspace(): IWorkspace { + return this.workspace; + } + + getWorkbenchState(): WorkbenchState { + return WorkbenchState.WORKSPACE; + } + + hasWorkspaceData(): boolean { + return true; + } + + getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { + return this.workspace.getFolder(resource); + } + + public isInsideWorkspace(resource: URI): boolean { + return !!this.getWorkspaceFolder(resource); + } + + public isCurrentWorkspace(workspaceIdOrFolder: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI): boolean { + return false; + } + + public addFolders(foldersToAdd: IWorkspaceFolderCreationData[]): Promise { + return this.doUpdateFolders(foldersToAdd, []); + } + + public removeFolders(foldersToRemove: URI[]): Promise { + return this.doUpdateFolders([], foldersToRemove); + } + + public async updateFolders(index: number, deleteCount?: number, foldersToAddCandidates?: IWorkspaceFolderCreationData[]): Promise { + const folders = this.workspace.folders; + + let foldersToDelete: URI[] = []; + if (typeof deleteCount === 'number') { + foldersToDelete = folders.slice(index, index + deleteCount).map(folder => folder.uri); + } + + let foldersToAdd: IWorkspaceFolderCreationData[] = []; + if (Array.isArray(foldersToAddCandidates)) { + foldersToAdd = foldersToAddCandidates.map(folderToAdd => ({ uri: removeTrailingPathSeparator(folderToAdd.uri), name: folderToAdd.name })); + } + + return this.doUpdateFolders(foldersToAdd, foldersToDelete, index); + } + + async enterWorkspace(_path: URI): Promise { } + + async createAndEnterWorkspace(_folders: IWorkspaceFolderCreationData[], _path?: URI): Promise { } + + async saveAndEnterWorkspace(_path: URI): Promise { } + + async copyWorkspaceSettings(_toWorkspace: IWorkspaceIdentifier): Promise { } + + async pickNewWorkspacePath(): Promise { return undefined; } + + private doUpdateFolders(foldersToAdd: IWorkspaceFolderCreationData[], foldersToRemove: URI[], index?: number): Promise { + return this._updateFoldersQueue.queue(() => this._doUpdateFolders(foldersToAdd, foldersToRemove, index)); + } + + private async _doUpdateFolders(foldersToAdd: IWorkspaceFolderCreationData[], foldersToRemove: URI[], index?: number): Promise { + if (foldersToAdd.length === 0 && foldersToRemove.length === 0) { + return; + } + + const currentFolders = this.workspace.folders; + + // Remove folders + let newFolders = currentFolders.filter(folder => + !foldersToRemove.some(toRemove => this.uriIdentityService.extUri.isEqual(folder.uri, toRemove)) + ); + + // Add folders + const foldersToAddWorkspaceFolders = foldersToAdd + .filter(folderToAdd => !newFolders.some(existing => this.uriIdentityService.extUri.isEqual(existing.uri, folderToAdd.uri))) + .map(folderToAdd => new WorkspaceFolder( + { uri: folderToAdd.uri, name: folderToAdd.name || this.uriIdentityService.extUri.basenameOrAuthority(folderToAdd.uri), index: 0 }, + { uri: folderToAdd.uri.toString() } + )); + + if (foldersToAddWorkspaceFolders.length > 0) { + if (typeof index === 'number' && index >= 0 && index < newFolders.length) { + newFolders = [...newFolders.slice(0, index), ...foldersToAddWorkspaceFolders, ...newFolders.slice(index)]; + } else { + newFolders = [...newFolders, ...foldersToAddWorkspaceFolders]; + } + } + + // Recompute indices + newFolders = newFolders.map((f, i) => new WorkspaceFolder({ uri: f.uri, name: f.name, index: i }, f.raw)); + + // Compute change event + const added = newFolders.filter(folder => !currentFolders.some(existing => this.uriIdentityService.extUri.isEqual(existing.uri, folder.uri))); + const removed = currentFolders.filter(folder => !newFolders.some(existing => this.uriIdentityService.extUri.isEqual(existing.uri, folder.uri))); + const changed: IWorkspaceFolder[] = []; + const changes: IWorkspaceFoldersChangeEvent = { added, removed, changed }; + + if (added.length === 0 && removed.length === 0) { + return; + } + + // Fire will change event + const joinPromises: Promise[] = []; + this._onWillChangeWorkspaceFolders.fire({ + changes, + fromCache: false, + join(promise: Promise) { joinPromises.push(promise); } + }); + await Promise.allSettled(joinPromises); + + // Update workspace + const workspaceIdentifier = getWorkspaceIdentifier(this.workspace.configuration!); + const workspace = new Workspace(workspaceIdentifier.id, newFolders, false, workspaceIdentifier.configPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); + this.workspace.update(workspace); + + // Fire did change event + this._onDidChangeWorkspaceFolders.fire(changes); + } +} diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index e4be5c11daf50..046d6d2d94590 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -212,6 +212,7 @@ import '../workbench/contrib/chat/browser/chat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; +import '../workbench/contrib/imageCarousel/browser/imageCarousel.contribution.js'; // Interactive import '../workbench/contrib/interactive/browser/interactive.contribution.js'; @@ -442,3 +443,31 @@ import '../workbench/contrib/editTelemetry/browser/editTelemetry.contribution.js import '../workbench/contrib/opener/browser/opener.contribution.js'; //#endregion + +//#region --- sessions contributions + +import './browser/paneCompositePartService.js'; +import './browser/layoutActions.js'; + +import './contrib/accountMenu/browser/account.contribution.js'; +import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; +import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/customizationsDebugLog.contribution.js'; +import './contrib/copilotChatSessions/browser/copilotChatSessions.contribution.js'; +import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/sessions/browser/customizationsToolbar.contribution.js'; +import './contrib/changes/browser/changesView.contribution.js'; +import './contrib/codeReview/browser/codeReview.contributions.js'; +import './contrib/files/browser/files.contribution.js'; +import './contrib/github/browser/github.contribution.js'; +import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; +import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed +import './contrib/configuration/browser/configuration.contribution.js'; + +import './contrib/terminal/browser/sessionsTerminalContribution.js'; +import './contrib/logs/browser/logs.contribution.js'; +import './contrib/chatDebug/browser/chatDebug.contribution.js'; +import './contrib/workspace/browser/workspace.contribution.js'; +import './contrib/welcome/browser/welcome.contribution.js'; + +//#endregion diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index fcf7a0bc195d5..3f566196d8ee5 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -42,12 +42,11 @@ import '../workbench/services/update/electron-browser/updateService.js'; import '../workbench/services/url/electron-browser/urlService.js'; import '../workbench/services/lifecycle/electron-browser/lifecycleService.js'; import '../workbench/services/host/electron-browser/nativeHostService.js'; -import './electron-browser/titleService.js'; +import './services/title/electron-browser/titleService.js'; import '../platform/meteredConnection/electron-browser/meteredConnectionService.js'; import '../workbench/services/request/electron-browser/requestService.js'; import '../workbench/services/clipboard/electron-browser/clipboardService.js'; import '../workbench/services/contextmenu/electron-browser/contextmenuService.js'; -import '../workbench/services/workspaces/electron-browser/workspaceEditingService.js'; import '../workbench/services/configurationResolver/electron-browser/configurationResolverService.js'; import '../workbench/services/accessibility/electron-browser/accessibilityService.js'; import '../workbench/services/keybinding/electron-browser/nativeKeyboardLayout.js'; @@ -178,7 +177,6 @@ import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribu // Chat import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; -//import '../workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.js'; import './contrib/agentFeedback/browser/agentFeedback.contribution.js'; // Encryption @@ -198,20 +196,10 @@ import '../workbench/contrib/policyExport/electron-browser/policyExport.contribu //#region --- sessions contributions -import './browser/paneCompositePartService.js'; -import './browser/layoutActions.js'; - -import './contrib/accountMenu/browser/account.contribution.js'; -import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; -import './contrib/chat/browser/chat.contribution.js'; -import './contrib/sessions/browser/sessions.contribution.js'; -import './contrib/sessions/browser/customizationsToolbar.contribution.js'; -import './contrib/changesView/browser/changesView.contribution.js'; -import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed -import './contrib/configuration/browser/configuration.contribution.js'; -import './contrib/welcome/browser/welcome.contribution.js'; -import './contrib/terminal/browser/sessionsTerminalContribution.js'; -import './contrib/logs/browser/logs.contribution.js'; +// Remote Agent Host +import '../platform/agentHost/electron-browser/agentHostService.js'; +import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; +import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; //#endregion diff --git a/src/vs/sessions/sessions.web.main.internal.ts b/src/vs/sessions/sessions.web.main.internal.ts new file mode 100644 index 0000000000000..4e7ae75ffc2bd --- /dev/null +++ b/src/vs/sessions/sessions.web.main.internal.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This file is the web embedder entry point for the Sessions workbench. +// It mirrors workbench.web.main.internal.ts but loads the sessions entry +// point and factory instead of the standard workbench ones. + +import './sessions.web.main.js'; +import { create } from './browser/web.factory.js'; +import { URI } from '../base/common/uri.js'; +import { Event, Emitter } from '../base/common/event.js'; +import { Disposable } from '../base/common/lifecycle.js'; +import { LogLevel } from '../platform/log/common/log.js'; + +export { + create, + URI, + Event, + Emitter, + Disposable, + LogLevel, +}; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts new file mode 100644 index 0000000000000..705e2eb7f8519 --- /dev/null +++ b/src/vs/sessions/sessions.web.main.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +// ####################################################################### +// ### ### +// ### !!! PLEASE ADD COMMON IMPORTS INTO SESSIONS.COMMON.MAIN.TS !!! ### +// ### ### +// ####################################################################### + +//#region --- sessions common + +import './sessions.common.main.js'; + +//#endregion + + +//#region --- workbench parts + +import '../workbench/browser/parts/dialogs/dialog.web.contribution.js'; + +//#endregion + + +//#region --- sessions (web main) — sessions-specific web bootstrap + +import './browser/web.main.js'; + +//#endregion + + +//#region --- workbench services (browser equivalents of the electron services) + +import '../workbench/services/integrity/browser/integrityService.js'; +import '../workbench/services/search/browser/searchService.js'; +import '../workbench/services/textfile/browser/browserTextFileService.js'; +import '../workbench/services/keybinding/browser/keyboardLayoutService.js'; +import '../workbench/services/extensions/browser/extensionService.js'; +import '../workbench/services/extensionManagement/browser/extensionsProfileScannerService.js'; +import '../workbench/services/extensions/browser/extensionsScannerService.js'; +import '../workbench/services/extensionManagement/browser/webExtensionsScannerService.js'; +import '../workbench/services/extensionManagement/common/extensionManagementServerService.js'; +import '../workbench/services/mcp/browser/mcpWorkbenchManagementService.js'; +import '../workbench/services/extensionManagement/browser/extensionGalleryManifestService.js'; +import '../workbench/services/telemetry/browser/telemetryService.js'; +import '../workbench/services/url/browser/urlService.js'; +import '../workbench/services/update/browser/updateService.js'; +import '../workbench/services/workspaces/browser/workspacesService.js'; +import '../workbench/services/workspaces/browser/workspaceEditingService.js'; +import '../workbench/services/dialogs/browser/fileDialogService.js'; +import '../workbench/services/host/browser/browserHostService.js'; +import '../platform/meteredConnection/browser/meteredConnectionService.js'; +import '../workbench/services/lifecycle/browser/lifecycleService.js'; +import '../workbench/services/clipboard/browser/clipboardService.js'; +import '../workbench/services/localization/browser/localeService.js'; +import '../workbench/services/path/browser/pathService.js'; +import '../workbench/services/themes/browser/browserHostColorSchemeService.js'; +import '../workbench/services/encryption/browser/encryptionService.js'; +import '../workbench/services/imageResize/browser/imageResizeService.js'; +import '../workbench/services/secrets/browser/secretStorageService.js'; +import '../workbench/services/workingCopy/browser/workingCopyBackupService.js'; +import '../workbench/services/tunnel/browser/tunnelService.js'; +import '../workbench/services/files/browser/elevatedFileService.js'; +import '../workbench/services/workingCopy/browser/workingCopyHistoryService.js'; +import '../workbench/services/userDataSync/browser/webUserDataSyncEnablementService.js'; +import '../workbench/services/userDataProfile/browser/userDataProfileStorageService.js'; +import '../workbench/services/configurationResolver/browser/configurationResolverService.js'; +import '../platform/extensionResourceLoader/browser/extensionResourceLoaderService.js'; +import '../workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.js'; +import '../workbench/services/browserElements/browser/webBrowserElementsService.js'; +import '../workbench/services/power/browser/powerService.js'; + +import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; +import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js'; +import { IContextMenuService } from '../platform/contextview/browser/contextView.js'; +import { ContextMenuService } from '../platform/contextview/browser/contextMenuService.js'; +import { IExtensionTipsService } from '../platform/extensionManagement/common/extensionManagement.js'; +import { ExtensionTipsService } from '../platform/extensionManagement/common/extensionTipsService.js'; +import { IWorkbenchExtensionManagementService } from '../workbench/services/extensionManagement/common/extensionManagement.js'; +import { ExtensionManagementService } from '../workbench/services/extensionManagement/common/extensionManagementService.js'; +import { UserDataSyncMachinesService, IUserDataSyncMachinesService } from '../platform/userDataSync/common/userDataSyncMachines.js'; +import { IUserDataSyncStoreService, IUserDataSyncService, IUserDataAutoSyncService, IUserDataSyncLocalStoreService, IUserDataSyncResourceProviderService } from '../platform/userDataSync/common/userDataSync.js'; +import { UserDataSyncStoreService } from '../platform/userDataSync/common/userDataSyncStoreService.js'; +import { UserDataSyncLocalStoreService } from '../platform/userDataSync/common/userDataSyncLocalStoreService.js'; +import { UserDataSyncService } from '../platform/userDataSync/common/userDataSyncService.js'; +import { IUserDataSyncAccountService, UserDataSyncAccountService } from '../platform/userDataSync/common/userDataSyncAccount.js'; +import { UserDataAutoSyncService } from '../platform/userDataSync/common/userDataAutoSyncService.js'; +import { AccessibilityService } from '../platform/accessibility/browser/accessibilityService.js'; +import { ICustomEndpointTelemetryService } from '../platform/telemetry/common/telemetry.js'; +import { NullEndpointTelemetryService } from '../platform/telemetry/common/telemetryUtils.js'; +import './services/title/browser/titleService.js'; +import { ITimerService, TimerService } from '../workbench/services/timer/browser/timerService.js'; +import { IDiagnosticsService, NullDiagnosticsService } from '../platform/diagnostics/common/diagnostics.js'; +import { ILanguagePackService } from '../platform/languagePacks/common/languagePacks.js'; +import { WebLanguagePacksService } from '../platform/languagePacks/browser/languagePacks.js'; +import { IWebContentExtractorService, NullWebContentExtractorService, ISharedWebContentExtractorService, NullSharedWebContentExtractorService } from '../platform/webContentExtractor/common/webContentExtractor.js'; +import { IMcpGalleryManifestService } from '../platform/mcp/common/mcpGalleryManifest.js'; +import { WorkbenchMcpGalleryManifestService } from '../workbench/services/mcp/browser/mcpGalleryManifestService.js'; +import { UserDataSyncResourceProviderService } from '../platform/userDataSync/common/userDataSyncResourceProvider.js'; +import { IRemoteAgentHostService, NullRemoteAgentHostService } from '../platform/agentHost/common/remoteAgentHostService.js'; + +registerSingleton(IWorkbenchExtensionManagementService, ExtensionManagementService, InstantiationType.Delayed); +registerSingleton(IAccessibilityService, AccessibilityService, InstantiationType.Delayed); +registerSingleton(IContextMenuService, ContextMenuService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncStoreService, UserDataSyncStoreService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncMachinesService, UserDataSyncMachinesService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncLocalStoreService, UserDataSyncLocalStoreService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncAccountService, UserDataSyncAccountService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncService, UserDataSyncService, InstantiationType.Delayed); +registerSingleton(IUserDataSyncResourceProviderService, UserDataSyncResourceProviderService, InstantiationType.Delayed); +registerSingleton(IUserDataAutoSyncService, UserDataAutoSyncService, InstantiationType.Eager); +registerSingleton(IExtensionTipsService, ExtensionTipsService, InstantiationType.Delayed); +registerSingleton(ITimerService, TimerService, InstantiationType.Delayed); +registerSingleton(ICustomEndpointTelemetryService, NullEndpointTelemetryService, InstantiationType.Delayed); +registerSingleton(IDiagnosticsService, NullDiagnosticsService, InstantiationType.Delayed); +registerSingleton(ILanguagePackService, WebLanguagePacksService, InstantiationType.Delayed); +registerSingleton(IWebContentExtractorService, NullWebContentExtractorService, InstantiationType.Delayed); +registerSingleton(ISharedWebContentExtractorService, NullSharedWebContentExtractorService, InstantiationType.Delayed); +registerSingleton(IMcpGalleryManifestService, WorkbenchMcpGalleryManifestService, InstantiationType.Delayed); +registerSingleton(IRemoteAgentHostService, NullRemoteAgentHostService, InstantiationType.Delayed); + +//#endregion + + +//#region --- workbench contributions (browser versions) + +import '../workbench/contrib/logs/browser/logs.contribution.js'; +import '../workbench/contrib/localization/browser/localization.contribution.js'; +import '../workbench/contrib/performance/browser/performance.web.contribution.js'; +import '../workbench/contrib/preferences/browser/keyboardLayoutPicker.js'; +import '../workbench/contrib/debug/browser/extensionHostDebugService.js'; +import '../workbench/contrib/welcomeBanner/browser/welcomeBanner.contribution.js'; +import '../workbench/contrib/webview/browser/webview.web.contribution.js'; +import '../workbench/contrib/extensions/browser/extensions.web.contribution.js'; +import '../workbench/contrib/terminal/browser/terminal.web.contribution.js'; +import '../workbench/contrib/externalTerminal/browser/externalTerminal.contribution.js'; +import '../workbench/contrib/terminal/browser/terminalInstanceService.js'; +import '../workbench/contrib/tasks/browser/taskService.js'; +import '../workbench/contrib/tags/browser/workspaceTagsService.js'; +import '../workbench/contrib/issue/browser/issue.contribution.js'; +import '../workbench/contrib/splash/browser/splash.contribution.js'; +import '../workbench/contrib/remote/browser/remoteStartEntry.contribution.js'; +import '../workbench/contrib/processExplorer/browser/processExplorer.web.contribution.js'; +import '../workbench/contrib/browserView/browser/browserView.contribution.js'; + +//#endregion diff --git a/src/vs/sessions/skills/commit/SKILL.md b/src/vs/sessions/skills/commit/SKILL.md new file mode 100644 index 0000000000000..2bd73ac44c966 --- /dev/null +++ b/src/vs/sessions/skills/commit/SKILL.md @@ -0,0 +1,80 @@ +--- +name: commit +description: Commit staged or unstaged changes with an AI-generated commit message that matches the repository's existing commit style. Use when the user asks to 'commit', 'commit changes', 'create a commit', 'save my work', or 'check in code'. +--- + + +# Commit Changes + +Help the user commit code changes with a well-crafted commit message derived from the diff, following the conventions already established in the repository. + +## Guidelines + +- **Never amend existing commits** without asking. +- **Never force-push or push** without explicit user approval. +- **Never skip pre-commit hooks** (do not use `--no-verify`). +- **Never skip signing commits** (do not use `--no-gpg-sign`). +- **Never revert, reset, or discard user changes** unless the user explicitly asked for that. +- Check for obvious secrets or generated artifacts that should not be committed. If something looks risky - ask the user. +- When in doubt about staging, convention, or message content — ask the user. + +## Workflow + +### 1. Discover the repository's commit convention + +Run the following to sample recent commits and the user's own commits: + +``` +# Recent repo commits (for overall style) +git log --oneline -20 + +# User's recent commits (for personal style) +git log --oneline --author="$(git config user.name)" -10 +``` + +Analyse the output to determine the commit message convention used in the repository (e.g. Conventional Commits, Gitmoji, ticket-prefixed, free-form). All generated messages **must** follow the detected convention. + +### 2. Check repository status + +``` +git status --short +``` + +- If there are **no changes** (working tree clean, nothing staged), inform the user and stop. +- If there are **staged changes**, proceed with those and do not stage any unstaged changes. +- If there are **only unstaged changes**, stage everything (`git add -A`), and proceed with those. + +### 3. Generate the commit message + +Obtain the full diff of what will be committed: + +```bash +git diff --cached --stat +git diff --cached +``` + +Using the diff and the commit convention detected in step 1, draft a commit message with: + +- A **subject line** (≤ 72 characters) that summarises the change, following the repository's convention. +- An optional **body** that explains *why* the change was made, only when the diff is non-trivial. +- Reference issue/ticket numbers when they appear in branch names or related context. +- Focus on the intent of the change, not a file-by-file inventory. + +### 4. Commit + +Construct the `git commit` command with the generated message. + +Execute the commit: + +``` +git commit -m "" -m "" +``` + +### 5. Confirm + +After the commit: + +- Run `git status --short` to confirm the commit completed. +- Run `git log --oneline -1` to show the new commit. +- If pre-commit hooks changed files or blocked the commit, summarize exactly what happened. +- If hooks rewrote files after the commit attempt, do not amend automatically. Tell the user what changed and ask whether they want you to stage and commit those follow-up edits. diff --git a/src/vs/sessions/skills/update-skills/SKILL.md b/src/vs/sessions/skills/update-skills/SKILL.md new file mode 100644 index 0000000000000..56dad1140f239 --- /dev/null +++ b/src/vs/sessions/skills/update-skills/SKILL.md @@ -0,0 +1,114 @@ +--- +name: update-skills +description: Create or update repository skills and instructions when major learnings are discovered during a session. Use when the user says "learn!", when a significant pattern or pitfall is identified, or when reusable domain knowledge should be captured for future sessions. +--- + + +# Update Skills & Instructions + +When a major repository learning is discovered — a recurring pattern, a non-obvious pitfall, a crucial architectural constraint, or domain knowledge that would save future sessions significant time — capture it as a skill or instruction so it persists across sessions. + +## When to Use + +- The user explicitly says **"learn!"** or asks to capture a learning +- You discover a significant pattern or constraint that cost meaningful debugging time +- You identify reusable domain knowledge that isn't documented anywhere in the repo +- A correction from the user reveals a general principle worth preserving + +## Decision: Skill vs Instruction vs Learning + +**Add a learning to an existing instruction** when: +- The insight is small (1-4 sentences) and fits naturally into an existing instruction file +- It refines or extends an existing guideline +- Follow the pattern in `.github/instructions/learnings.instructions.md` + +**Create or update a skill** (`.github/skills/{name}/SKILL.md` or `.agents/skills/{name}/SKILL.md`) when: +- The knowledge is substantial (multi-step procedure, detailed guidelines, or rich examples) +- It covers a distinct domain area (e.g., "how to debug X", "patterns for Y") +- Future sessions should be able to invoke it by name + +**Create or update an instruction** (`.github/instructions/{name}.instructions.md`) when: +- The rule should apply automatically based on file patterns (`applyTo`) or globally +- It's a coding convention, architectural constraint, or process rule +- It doesn't need to be invoked on demand + +## Procedure + +### 1. Identify the Learning + +Reflect on what went wrong or what was discovered: +- What was the problem or unexpected behavior? +- Why was it a problem? (root cause, not symptoms) +- How was it fixed or what's the correct approach? +- Can it be generalized beyond this specific instance? + +### 2. Check for Existing Files + +Before creating new files, search for existing skills and instructions that might be the right home: + +``` +# Check existing skills +ls .github/skills/ .agents/skills/ 2>/dev/null + +# Check existing instructions +ls .github/instructions/ 2>/dev/null + +# Search for related content +grep -r "related-keyword" .github/skills/ .github/instructions/ .agents/skills/ +``` + +### 3a. Add to Existing File + +If an appropriate file exists, add the learning to its `## Learnings` section (create the section if it doesn't exist). Each learning should be 1-4 sentences. + +### 3b. Create a New Skill + +If the knowledge warrants a standalone skill: + +1. Choose the location: + - `.github/skills/{name}/SKILL.md` for project-level skills (committed to repo) + - `.agents/skills/{name}/SKILL.md` for agent-specific skills +2. Create the directory and SKILL.md with frontmatter: + +```markdown +--- +name: {skill-name} +description: {One-line description of when and why to use this skill.} +--- + +# {Skill Title} + +{Body with guidelines, procedures, examples, and learnings.} +``` + +3. The `name` field **must match** the parent folder name exactly. +4. Include concrete examples — skills with examples are far more useful than abstract rules. + +### 3c. Create a New Instruction + +If the knowledge should apply automatically: + +```markdown +--- +description: {When these instructions should be loaded} +applyTo: '{glob pattern}' # optional — auto-load when matching files are attached +--- + +{Content of the instruction.} +``` + +### 4. Quality Checks + +Before saving: +- Is the learning **general enough** to help future sessions, not just this one? +- Is it **specific enough** to be actionable, not just a vague principle? +- Does it include a **concrete example** of right vs wrong? +- Does it avoid duplicating knowledge already captured elsewhere? +- Is the description clear enough that the agent will know **when** to invoke/apply it? + +### 5. Inform the User + +After creating or updating the file: +- Summarize what was captured and where +- Explain why this location was chosen +- Note if any existing content was updated vs new content created diff --git a/src/vs/sessions/test/ai-customizations.test.md b/src/vs/sessions/test/ai-customizations.test.md new file mode 100644 index 0000000000000..75fa33d55de82 --- /dev/null +++ b/src/vs/sessions/test/ai-customizations.test.md @@ -0,0 +1,207 @@ +# AI Customizations Test Plan + +The following test plan outlines the scenarios and specifications for the AI Customizations feature, which includes a management editor and tree view for managing customization items. + +## SPECS + +- [`../AI_CUSTOMIZATIONS.md`](../AI_CUSTOMIZATIONS.md) + +## SCENARIOS + +### Scenario 1: Empty state — no session, no customizations + +#### Description + +This tests the baseline empty state before any session or workspace is active. The 'new AI developer' state - who doesn't have any customizations on their machine yet. + +#### Preconditions + +- On 'New Session' screen +- No folder selected +- No user customizations created (from this tool or others i.e. Copilot CLI) + +#### Actions + +1. Open the sidebar customizations section +2. Observe no sidebar counts are shown for any section (Agents, Skills, Instructions, Prompts, Hooks) +3. Open the management editor by clicking on one of the sections (e.g., "Instructions") +4. Observe the empty state messages +5. Click through each section in the sidebar +6. Run Developer: Customizations Debug and read the report + +#### Expected Results + +- All sidebar counts are hidden (no badges visible) +- Management editor shows empty state for each section with "No X yet" message +- Create button for **user** customizations is visible but disabled until a workspace folder or repository is selected (Hooks should also show a disabled button, since there is no 'user' scoped hooks) + +#### Notes + +- The `Window: Sessions` should be verified by running `Developer: Customizations Debug` +- No workspace root should be active, verified via `Developer: Customizations Debug` (active root = none) + +--- + +### Scenario 2: Active workspace selected from new session state + +#### Description + +This tests the transition from the empty state to having an active workspace selected, but before a worktree is checked out (i.e., before starting a task). This is the 'new session' state where the user has selected a repository but hasn't started working in a specific branch or worktree yet. Customizations should be loaded from the repository root, not a worktree, and counts should reflect that. + +#### Preconditions + +- On 'New Session' screen (Scenario 1 completed) +- A git repository, cloned on the machine, is available to select + - For this test use `microsoft/vscode` cloned to a test folder + +#### Actions + +1. From the new session screen, select a workspace folder +2. Observe the sidebar customization counts update +3. Open the management editor by clicking on "Instructions" +4. Observe items appear in the "Workspace" group +5. Note the workspace item count in the group header +6. Compare the sidebar badge count with the editor's workspace item count — they should match +7. Click "Agents" in the sidebar +8. Observe agent items listed with parsed friendly names (not raw filenames) and a description +9. Click "Skills" in the sidebar +10. Observe skills listed with names derived from folder names +11. Click "Prompts" in the sidebar +12. Observe only prompt-type items (no skills mixed in, although note there may be similarly named items) +13. Click "Hooks" in the sidebar +14. Observe only workspace-scoped hook files (no user-level `~/.claude/settings.json`) +15. Run Developer: Customizations Debug and read the report + +#### Expected Results + +- Sidebar counts update from 0 to reflect the selected workspace's customizations +- Sidebar badge count matches editor list count for every section +- Instructions includes root-level files (AGENTS.md, CLAUDE.md, copilot-instructions.md) under "Workspace" +- Instructions includes `.instructions.md` files from `.github/instructions/` +- Agents shows friendly names (e.g., "Optimize" not "optimize.agent.md") +- Prompts excludes skill-type slash commands +- Hooks shows only workspace-local files (filter: `sources: [local]`) +- No "Extensions" or "Plugins" groups visible +- If user-level files exist in `~/.copilot/` or `~/.claude/`, a "User" group appears for applicable sections +- Debug report shows `Window: Sessions`, `Active root: /path/to/repository` +- Create button shows both "Workspace" and "User" options in dropdown + +#### Notes + +- The active root comes from the repository, not a worktree + +--- + +### Scenario 3: Create new workspace instruction in an active worktree session + +#### Preconditions + +- Active session with a worktree checked out (task started and running) +- Use the same repository as Scenario 2 (`microsoft/vscode`) + +#### Actions + +1. Observe sidebar customization counts reflect the worktree's customizations and are the same as Scenario 2 (since new worktree inherits from repo root, counts should be the same) +2. Open the management editor by clicking on "Instructions" +3. Observe items listed — should match files in the worktree (not the bare repo) +4. Verify there is a primary button "New Instructions (Workspace)" and another option in the dropdown for "New Instructions (User)" +5. Click the "+ New Instructions (Workspace)" button (primary action) +6. Select a name `` when the quickpick appears and confirm +7. Verify the file opens in the embedded editor +8. Verify the file path shown in the editor header is `/.github/instructions/.instructions.md` +9. Update the instruction file with some content, then press the back button +10. Confirm the instruction file was auto-committed and shows up in the worktree changes list +11. Reopen the customization management editor and click on "Instructions" again +12. Observe the new instruction appears in the "Workspace" group +13. Observe the sidebar badge count has incremented by 1 + +#### Expected Results + +- Active root is the worktree path, not the repository path +- File is created under the worktree's `.github/instructions/` folder (not the bare repo) +- File auto-saves and auto-commits to the worktree +- Item count updates in both the sidebar badge and editor list after creation +- The new file appears in the list with a friendly name derived from the filename + +#### Notes + +- This is the primary creation flow — workspace instructions are the most common customization type +- Key difference from Scenario 2: active root is the worktree, creation targets the worktree + +--- + +### Scenario 4: Create new user instruction in an active worktree session + +#### Preconditions + +- Active session with a worktree checked out (continuing from Scenario 3) + +#### Actions + +1. Open the management editor by clicking on "Instructions" +2. Click the "Add" dropdown arrow → click "New Instruction (User)" +3. Select a name `` when the quickpick appears and confirm +4. Verify the file opens in the embedded editor +5. Verify the file path shown in the editor header is `~/.copilot/instructions/.instructions.md` +6. Confirm the path is NOT the VS Code profile folder (e.g., NOT `~/.vscode-oss-sessions-dev/User/...`) +7. Press the back button to return to the list +8. Observe the new instruction appears in the "User" group +9. Observe the sidebar badge count reflects the new user instruction +10. Run Developer: Customizations Debug +11. Check the "Source Folders (creation targets)" section — verify `[user]` points to `~/.copilot/instructions` + +#### Expected Results + +- User file is created under `~/.copilot/instructions/` (not the VS Code profile folder) +- The file appears in the "User" group in the list +- Sidebar badge count includes the new user file +- Debug report confirms the user creation target is `~/.copilot/instructions` + +#### Notes + +- This validates that `AgenticPromptsService.getSourceFolders()` correctly redirects user creation to `~/.copilot/` +- The VS Code profile folder should never be used for user creation in sessions + +--- + +### Scenario 5: Create a new hook in an active worktree session + +#### Preconditions + +- Active session with a worktree checked out (continuing from Scenario 3) +- No existing `hooks.json` in the worktree's `.github/hooks/` folder + +#### Actions + +1. Open the management editor by clicking on "Hooks" +2. Observe the current hook items (if any) +3. Click the "Add" button → observe a `hooks.json` is created +4. Verify the hooks.json opens in the embedded editor +5. Verify the file path is `/.github/hooks/hooks.json` +6. Read the generated JSON and check: + - `"version": 1` is present at the top level + - Hook entries use `"bash"` as the shell field (not `"command"`) + - All hook event types are present: `sessionStart`, `userPromptSubmitted`, `preToolUse`, `postToolUse` + - Each event has a `[{ "type": "command", "bash": "" }]` skeleton +7. Edit one of the hook entries (e.g., add a bash command to `sessionStart`) +8. Press the back button to return to the list +9. Observe the hooks.json appears in the "Workspace" group +10. Observe the sidebar badge count for Hooks has updated +11. Run Developer: Customizations Debug on the Hooks section +12. Verify `Active root` points to the worktree path +13. Compare Stage 1 counts with Stage 3 counts — they should be consistent + +#### Expected Results + +- Hooks.json is created in the worktree's `.github/hooks/` folder +- JSON skeleton has correct Copilot CLI format: `"version": 1`, `"bash"` field +- All hook events from `COPILOT_CLI_HOOK_TYPE_MAP` are present in the skeleton +- Hooks section shows only workspace-local hook files (no user-level hooks visible) +- Item count updates after creation +- Debug report Stage 1 → Stage 3 pipeline shows no unexpected filtering + +#### Notes + +- Hook events are derived from `COPILOT_CLI_HOOK_TYPE_MAP` — adding new events to the schema auto-includes them in the skeleton +- Only `"bash"` is used (not `"command"`) to match the Copilot CLI schema +- The `"version": 1` field is required by the CLI for format detection diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index 786236ec9703a..f8362f7d6549b 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -16,7 +16,7 @@ suite('Sessions - Layout Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('always-on-top toggle action is contributed to TitleBarRight', () => { - const items = MenuRegistry.getMenuItems(Menus.TitleBarRight); + const items = MenuRegistry.getMenuItems(Menus.TitleBarRightLayout); const menuItems = items.filter(isIMenuItem); const toggleAlwaysOnTop = menuItems.find(item => item.command.id === 'workbench.action.toggleWindowAlwaysOnTop'); diff --git a/src/vs/sessions/test/e2e/.gitignore b/src/vs/sessions/test/e2e/.gitignore new file mode 100644 index 0000000000000..141d99daed58e --- /dev/null +++ b/src/vs/sessions/test/e2e/.gitignore @@ -0,0 +1,3 @@ +out/ +*.png +node_modules/ diff --git a/src/vs/sessions/test/e2e/README.md b/src/vs/sessions/test/e2e/README.md new file mode 100644 index 0000000000000..347d0660da970 --- /dev/null +++ b/src/vs/sessions/test/e2e/README.md @@ -0,0 +1,392 @@ +# Agent Sessions — E2E Tests + +Automated dogfooding tests for the Agent Sessions window using a +**compile-and-replay** architecture powered by +[`playwright-cli`](https://github.com/microsoft/playwright-cli) and Copilot CLI. + +## Mocking Architecture + +These tests run the **real** Sessions workbench with only the minimal set of +services mocked — specifically the services that require external backends +(auth, LLM, git). Everything downstream from the mock agent's canned response +runs through the real code paths. + +### What's Mocked (Minimal) + +| Service | Mock | Why | +|---------|------|-----| +| `IChatEntitlementService` | Returns `ChatEntitlement.Free` | No real Copilot account in CI | +| `IDefaultAccountService` | Returns a fake signed-in account | Hides the "Sign In" button | +| `IGitService` | Resolves immediately (no 10s barrier) | No real git extension in web tests | +| Chat agents (`copilotcli`, etc.) | Canned keyword-matched responses with `textEdit` progress items | No real LLM backend | +| `mock-fs://` FileSystemProvider | `InMemoryFileSystemProvider` registered directly in the workbench (not extension host) | Must be available before any service tries to resolve workspace files | +| GitHub authentication | Always-signed-in mock provider (extension) | No real OAuth flow | +| Code Review command | Returns canned review comments per file (extension) | No real Copilot AI review | +| PR commands (Create/Open/Merge) | No-op handlers that log and show info messages (extension) | No real GitHub API | + +### What's Real (Everything Else) + +The following services run with their **real** implementations, ensuring tests +exercise the actual code paths: + +- **`ChatEditingService`** — Processes `textEdit` progress items from the mock + agent, creates `IModifiedFileEntry` objects with real before/after diffs, and + computes actual `linesAdded`/`linesRemoved` from content changes +- **`ChatModel`** — Routes agent progress through `acceptResponseProgress()` +- **`ChangesViewPane`** — Reads file modification state from `IChatEditingService` + observables and renders the tree with real diff stats +- **Diff editor** — Opens a real diff view when clicking files in the changes list +- **Context keys** — `hasUndecidedChatEditingResourceContextKey`, + `hasAppliedChatEditsContextKey` are set by real `ModifiedFileEntryState` + observations +- **Menu actions** — "Create PR", "Accept", "Reject" buttons appear based on + real context key state +- **`CodeReviewService`** — Orchestrates review requests, processes results from + the mock `github.copilot.chat.codeReview.run` command, and stores comments +- **`CodeReviewToolbarContribution`** — Shows the Code Review button in the + Changes view toolbar based on real context key state + +### Data Flow + +``` +User types message → Chat Widget → ChatService + → Mock Agent invoke() → progress([{ kind: 'textEdit', uri, edits }]) + → ChatModel.acceptResponseProgress() + → ChatEditingService observes textEditGroup parts + → Creates IModifiedFileEntry per file + → Reads original content from mock-fs:// FileSystemProvider + → Computes real diff (linesAdded, linesRemoved) + → ChangesViewPane renders via observable chain + → Click file → Opens real diff editor +``` + +The mock agent is the **only** point where canned data enters the system. +Everything downstream uses real service implementations. + +### Code Review & PR Button Flow + +``` +Code Review button clicked → sessions.codeReview.run (core action) + → CodeReviewService.requestReview() + → commandService.executeCommand('chat.internal.codeReview.run') + → Bridge forwards to 'github.copilot.chat.codeReview.run' + → Mock extension returns canned comments + → CodeReviewService stores results, updates observable state + → CodeReviewToolbarContribution updates button icon/badge + +Create PR button clicked → github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR + → Mock extension logs and shows info message +``` + +The PR buttons (Create PR, Open PR, Merge) are contributed via the mock +extension's `package.json` menus, gated by `chatSessionType == copilotcli`. +The `chatSessionType` context key is derived from the session URI scheme +(`getChatSessionType()`), which returns `copilotcli` for mock sessions. + +### Why the FileSystem Provider Is Registered in the Workbench + +The `mock-fs://` `InMemoryFileSystemProvider` is registered directly on +`IFileService` inside `TestSessionsBrowserMain.createWorkbench()` — **not** in +the mock extension. This is critical because several workbench services +(SnippetsService, AgenticPromptFilesLocator, MCP, etc.) try to resolve files +in the workspace folder **before** the extension host activates. If the +provider were only registered via `vscode.workspace.registerFileSystemProvider()` +in the extension, these services would see `ENOPRO: No file system provider` +errors and fail silently. + +The mock extension still registers a `mock-fs` provider via the extension API +(needed for extension host operations), but the workbench-level registration +is the source of truth. + +### File Edit Strategy + +Mock edits target files that exist in the `mock-fs://` file store so the +`ChatEditingService` can compute real before/after diffs: + +- **Existing files** (e.g. `/mock-repo/src/index.ts`, `/mock-repo/package.json`) — edits use a + full-file replacement range (`line 1 → line 99999`) so the editing service + diffs the old content against the new content +- **New files** (e.g. `/mock-repo/src/build.ts`) — edits use an insert-at-beginning + range, producing a "file created" entry in the changes view + +### Mock Workspace Folder + +The workspace folder URI is `mock-fs://mock-repo/mock-repo`. The path +`/mock-repo` (not root `/`) is used so that `basename(folderUri)` returns +`"mock-repo"` — this is what the folder picker displays. All mock files are +stored under this path in the in-memory file store. + +## How It Works + +There are two phases: + +### Phase 1: Generate (uses LLM — slow, run once) + +```bash +npm run generate +``` + +For each `.scenario.md` file, the generate script: +1. Starts the Sessions web server and opens the page in `playwright-cli` +2. Takes an accessibility tree snapshot of the current page +3. Sends each natural-language step + snapshot to **Copilot CLI**, which returns + the exact `playwright-cli` commands (e.g. `click e43`, `type "hello"`) +4. Executes the commands to advance the UI state for the next step +5. Writes the compiled commands to a `.commands.json` file in the `scenarios/generated/` folder + +``` +scenarios/ +├── 01-repo-picker-on-submit.scenario.md ← human-written +├── 02-cloud-disables-add-run-action.scenario.md +└── generated/ + ├── 01-repo-picker-on-submit.commands.json ← agent-generated + └── 02-cloud-disables-add-run-action.commands.json +``` + +The `.commands.json` files are **committed to git** — they're the deterministic +test plan that everyone runs. + +### Phase 2: Test (no LLM — fast, deterministic) + +```bash +npm test +``` + +The test runner reads each `.commands.json` and replays the `playwright-cli` +commands mechanically. No LLM calls, no regex matching, no icon stripping. +Just sequential commands and assertions. + +### When to Re-generate + +Run `npm run generate` when: +- You add a new `.scenario.md` file +- The UI changes and refs are stale (tests start failing) +- You modify an existing scenario's steps + +## File Structure + +``` +e2e/ +├── common.cjs # Shared helpers (server, playwright-cli, parser) +├── generate.cjs # Compiles scenarios → .commands.json via Copilot CLI +├── test.cjs # Replays .commands.json deterministically +├── package.json # npm scripts: generate, test +├── extensions/ +│ └── sessions-e2e-mock/ # Mock extension (auth + mock-fs:// file system) +├── scenarios/ +│ ├── 01-chat-response.scenario.md +│ ├── 02-chat-with-changes.scenario.md +│ └── generated/ +│ ├── 01-chat-response.commands.json +│ └── 02-chat-with-changes.commands.json +├── .gitignore +└── README.md +``` + +Supporting files outside `e2e/`: + +``` +src/vs/sessions/test/ +├── web.test.ts # TestSessionsBrowserMain + MockChatAgentContribution +├── web.test.factory.ts # Factory for test workbench (replaces web.factory.ts) +└── sessions.web.test.internal.ts # Test entry point + +scripts/ +├── code-sessions-web.js # HTTP server that serves Sessions as a web app +└── code-sessions-web.sh # Shell wrapper +``` + +## Prerequisites + +- VS Code compiled (`out/` at the repo root): + ```bash + npm install && npm run compile + ``` +- Dependencies installed: + ```bash + cd src/vs/sessions/test/e2e && npm install + ``` +- Copilot CLI available (for `npm run generate` only): + ```bash + copilot --version + ``` + +## Running + +```bash +cd src/vs/sessions/test/e2e + +# First time or after UI changes: +npm run generate + +# Run tests (fast, deterministic): +npm test +``` + +Example test output: + +``` +Found 2 compiled scenario(s) + +Starting sessions web server on port 9542… +Server ready. + +▶ Scenario: Repository picker opens when submitting without a repo + ✅ step 1: Click button "Cloud" + ✅ step 2: Type "build the project" in the chat input + ✅ step 3: Press Enter to submit + ✅ step 4: Verify the repository picker dropdown is visible + +▶ Scenario: Switching to Cloud target disables the Add Run Action button + ✅ step 1: Click button "Cloud" + ✅ step 2: Click button "Local" + +Results: 6 passed, 0 failed +``` + +## Writing a New Scenario + +1. Create a new `NN-description.scenario.md` file in `scenarios/`. + Files are sorted by name and run in order. + +2. Use this format: + +```markdown +# Scenario: Short description of what this tests + +## Steps +1. Click button "Cloud" +2. Type "build the project" in the chat input +3. Press Enter to submit +4. Verify the repository picker dropdown is visible +``` + +3. Run `npm run generate` to compile it into a `.commands.json` file. + +4. Run `npm test` to verify it works. + +5. Commit both the `.scenario.md` and `.commands.json` files. + +### Step Language + +Write steps in plain English. The Copilot agent interprets them against the +page's accessibility tree. Common patterns: + +| Pattern | Example | +|---------|---------| +| Click a button | `Click button "Cloud"` | +| Type in an input | `Type "hello" in the chat input` | +| Press a key | `Press Enter` | +| Verify visibility | `Verify the repository picker dropdown is visible` | +| Verify button state | `Verify the "Send" button is disabled` | + +You're not limited to these patterns — the agent understands natural language. + +### The .commands.json Format + +Each compiled step looks like: + +```json +{ + "description": "Click button \"Cloud\"", + "commands": [ + "click e143" + ] +} +``` + +For assertions, the agent outputs a `snapshot` command followed by an assertion comment: + +```json +{ + "description": "Verify the repository picker dropdown is visible", + "commands": [ + "snapshot", + "# ASSERT_VISIBLE: Repository Picker" + ] +} +``` + +The test runner understands these comment-based assertions: +- `# ASSERT_VISIBLE: ` — checks snapshot contains the text +- `# ASSERT_DISABLED: