diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md new file mode 100644 index 0000000000000..2b564d812bb39 --- /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 Agents App (Agents Window) + +The Agents app is a separate workbench mode launched with the `--agents` 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 --agents --remote-debugging-port=9224 +``` + +Wait for the window to fully initialize, then connect: + +```bash +# Wait for Agents 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 `--agents` flag launches the Agents 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-accessor-after-await.ts b/.eslint-plugin-local/code-no-accessor-after-await.ts new file mode 100644 index 0000000000000..7f754429c2b4f --- /dev/null +++ b/.eslint-plugin-local/code-no-accessor-after-await.ts @@ -0,0 +1,421 @@ +/*--------------------------------------------------------------------------------------------- + * 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/utils'; +import * as eslint from 'eslint'; + +/** + * Lint rule that prevents using a `ServicesAccessor` after an `await` expression. + * + * The accessor returned by `IInstantiationService.invokeFunction` is only valid + * synchronously during the invocation of the target function. Calling + * `accessor.get(...)` after any `await` is a bug because the accessor will have + * been invalidated. + * + * Detection strategies: + * 1. `invokeFunction` / `invokeWithinContext` calls — first param of the callback + * is the accessor. + * 2. Functions/methods with a parameter typed as `ServicesAccessor` — these are + * always called through `invokeFunction` at runtime (e.g. `Action2.run`, + * `ICommandHandler`). + */ +export default new class NoAccessorAfterAwait implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + accessorAfterAwait: 'ServicesAccessor \'{{name}}\' must not be used after \'await\'. The accessor is only valid synchronously. Extract needed services before any async operation.', + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + return { + // Strategy 1: invokeFunction / invokeWithinContext calls + 'CallExpression': (node: eslint.Rule.Node) => { + const callExpression = node as unknown as TSESTree.CallExpression; + + if (!isInvokeFunctionCall(callExpression.callee)) { + return; + } + + const functionArg = callExpression.arguments.find(arg => + arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression' + ) as TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | undefined; + + if (!functionArg || functionArg.params.length === 0) { + return; + } + + const accessorName = getParamName(functionArg.params[0]); + if (!accessorName) { + return; + } + + checkForAccessorAfterAwait(functionArg, accessorName, context); + }, + + // Strategy 2: functions/methods with a `ServicesAccessor` typed parameter + 'FunctionDeclaration': (node: eslint.Rule.Node) => { + checkFunctionWithAccessorParam(node as unknown as TSESTree.FunctionDeclaration, context); + }, + 'FunctionExpression': (node: eslint.Rule.Node) => { + checkFunctionWithAccessorParam(node as unknown as TSESTree.FunctionExpression, context); + }, + 'ArrowFunctionExpression': (node: eslint.Rule.Node) => { + checkFunctionWithAccessorParam(node as unknown as TSESTree.ArrowFunctionExpression, context); + }, + }; + } +}; + +function checkFunctionWithAccessorParam( + fn: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression, + context: eslint.Rule.RuleContext +) { + for (const param of fn.params) { + if (param.type === 'Identifier' && hasServicesAccessorAnnotation(param)) { + // Skip if this function is the direct callback of an invokeFunction call + // (already handled by strategy 1) + if (isDirectInvokeFunctionCallback(fn)) { + return; + } + checkForAccessorAfterAwait(fn, param.name, context); + return; + } + } +} + +/** + * Check whether a function node is the direct callback argument of an + * `invokeFunction` / `invokeWithinContext` call. + */ +function isDirectInvokeFunctionCallback( + fn: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression +): boolean { + const parent = fn.parent; + if (parent?.type === 'CallExpression' && isInvokeFunctionCall(parent.callee)) { + return parent.arguments.some(arg => arg === fn); + } + return false; +} + +function hasServicesAccessorAnnotation(param: TSESTree.Identifier): boolean { + const annotation = param.typeAnnotation; + if (!annotation || annotation.type !== 'TSTypeAnnotation') { + return false; + } + const typeNode = annotation.typeAnnotation; + if (typeNode.type === 'TSTypeReference' && typeNode.typeName.type === 'Identifier') { + return typeNode.typeName.name === 'ServicesAccessor'; + } + return false; +} + +function checkForAccessorAfterAwait( + fn: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration, + accessorName: string, + context: eslint.Rule.RuleContext +) { + let sawAwait = false; + const visited = new Set(); + + function walk(node: TSESTree.Node) { + if (visited.has(node)) { + return; + } + visited.add(node); + + // Don't descend into nested function scopes — they have their own + // async context and the accessor name may be shadowed. + if (node !== fn && + (node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionExpression' || + node.type === 'FunctionDeclaration')) { + return; + } + + if (node.type === 'AwaitExpression') { + // Walk the argument first (it is evaluated before the await suspends) + if (node.argument) { + walk(node.argument); + } + sawAwait = true; + return; + } + + if (isAccessorUsage(node, accessorName) && sawAwait) { + context.report({ + node: node as unknown as eslint.Rule.Node, + messageId: 'accessorAfterAwait', + data: { name: accessorName }, + }); + return; + } + + // Branch-aware walking: isolate await state across branches so an + // await in one branch does not taint the other branch. + if (node.type === 'IfStatement') { + walk(node.test); + const beforeBranches = sawAwait; + + // Walk consequent + walk(node.consequent); + const awaitAfterConsequent = sawAwait; + const consequentExits = blockAlwaysExits(node.consequent); + + // Restore before walking alternate + sawAwait = beforeBranches; + if (node.alternate) { + walk(node.alternate); + } + const awaitAfterAlternate = sawAwait; + const alternateExits = node.alternate ? blockAlwaysExits(node.alternate) : false; + + // Determine sawAwait for code after the if-statement. + // If a branch always exits (return/throw), code after is only + // reachable from the other branch. + if (consequentExits && alternateExits) { + // Both exit — code after is unreachable, keep conservative + sawAwait = awaitAfterConsequent || awaitAfterAlternate; + } else if (consequentExits) { + // Only reachable through alternate path + sawAwait = awaitAfterAlternate; + } else if (alternateExits) { + // Only reachable through consequent path + sawAwait = awaitAfterConsequent; + } else { + sawAwait = awaitAfterConsequent || awaitAfterAlternate; + } + return; + } + + if (node.type === 'ConditionalExpression') { + walk(node.test); + const beforeBranches = sawAwait; + walk(node.consequent); + const awaitAfterConsequent = sawAwait; + sawAwait = beforeBranches; + walk(node.alternate); + sawAwait = sawAwait || awaitAfterConsequent; + return; + } + + if (node.type === 'SwitchStatement') { + walk(node.discriminant); + const beforeCases = sawAwait; + let anyCaseHadAwait = false; + for (const c of node.cases) { + sawAwait = beforeCases; + if (c.test) { walk(c.test); } + c.consequent.forEach(walk); + anyCaseHadAwait = anyCaseHadAwait || sawAwait; + } + sawAwait = anyCaseHadAwait; + return; + } + + if (node.type === 'TryStatement') { + const beforeTry = sawAwait; + walk(node.block); + const awaitAfterTry = sawAwait; + // Catch: an exception may have been thrown before or after an await + // in the try block, so we conservatively use the before-try state. + sawAwait = beforeTry; + if (node.handler) { walk(node.handler.body); } + const awaitAfterCatch = sawAwait; + sawAwait = awaitAfterTry || awaitAfterCatch; + if (node.finalizer) { walk(node.finalizer); } + return; + } + + // `for await...of` suspends on each iteration + if (node.type === 'ForOfStatement' && node.await) { + walkChildren(node, (child) => { + if (child === node.right) { + walk(child); + sawAwait = true; + } else { + walk(child); + } + }); + return; + } + + // Walk children in source order for all other node types + walkChildren(node, walk); + } + + if (fn.body) { + walk(fn.body); + } +} + +/** + * Check whether a statement or block always exits the current function scope + * via `return` or `throw`. Note: `break`/`continue` only exit loops, not the + * enclosing function, so they are intentionally excluded. + */ +function blockAlwaysExits(node: TSESTree.Node): boolean { + if (node.type === 'ReturnStatement' || node.type === 'ThrowStatement') { + return true; + } + if (node.type === 'BlockStatement' && node.body.length > 0) { + return blockAlwaysExits(node.body[node.body.length - 1]); + } + if (node.type === 'IfStatement') { + return blockAlwaysExits(node.consequent) && + !!node.alternate && blockAlwaysExits(node.alternate); + } + return false; +} + +/** + * Check if a node is a usage of the accessor — either `accessor.get(...)` or + * just a reference to the accessor identifier (e.g. passing it to another fn). + */ +function isAccessorUsage(node: TSESTree.Node, accessorName: string): boolean { + // accessor.get(...) + if (node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === accessorName) { + return true; + } + // Passing accessor as an argument: someFunction(accessor) + if (node.type === 'Identifier' && node.name === accessorName) { + // Only flag when used as a call argument or assignment, not in + // the function's own parameter list + const parent = node.parent; + if (parent?.type === 'CallExpression' && parent.arguments.includes(node)) { + return true; + } + } + return false; +} + +function walkChildren(node: TSESTree.Node, visit: (child: TSESTree.Node) => void) { + switch (node.type) { + case 'BlockStatement': + node.body.forEach(visit); + break; + case 'ExpressionStatement': + visit(node.expression); + break; + case 'VariableDeclaration': + node.declarations.forEach(decl => { + if (decl.init) { visit(decl.init); } + }); + break; + case 'CallExpression': + visit(node.callee); + node.arguments.forEach(visit); + break; + case 'MemberExpression': + visit(node.object); + if (node.computed) { visit(node.property); } + break; + + case 'ReturnStatement': + if (node.argument) { visit(node.argument); } + break; + case 'BinaryExpression': + case 'LogicalExpression': + visit(node.left); + visit(node.right); + break; + case 'AssignmentExpression': + visit(node.left); + visit(node.right); + break; + case 'TemplateLiteral': + node.expressions.forEach(visit); + break; + case 'TaggedTemplateExpression': + visit(node.tag); + visit(node.quasi); + break; + case 'ArrayExpression': + node.elements.forEach(e => { if (e) { visit(e); } }); + break; + case 'ObjectExpression': + node.properties.forEach(p => { + if (p.type === 'Property') { + visit(p.value); + } else { + visit(p); + } + }); + break; + case 'SpreadElement': + visit(node.argument); + break; + case 'UnaryExpression': + case 'UpdateExpression': + visit(node.argument); + break; + + case 'ForStatement': + if (node.init) { visit(node.init); } + if (node.test) { visit(node.test); } + if (node.update) { visit(node.update); } + visit(node.body); + break; + case 'ForInStatement': + visit(node.left); + visit(node.right); + visit(node.body); + break; + case 'ForOfStatement': + visit(node.left); + visit(node.right); + visit(node.body); + break; + case 'WhileStatement': + case 'DoWhileStatement': + visit(node.test); + visit(node.body); + break; + case 'ThrowStatement': + if (node.argument) { visit(node.argument); } + break; + case 'NewExpression': + visit(node.callee); + node.arguments.forEach(visit); + break; + case 'SequenceExpression': + node.expressions.forEach(visit); + break; + case 'TSAsExpression': + case 'TSNonNullExpression': + visit(node.expression); + break; + // Leaf / unhandled nodes — nothing to traverse + default: + break; + } +} + +function getParamName(param: TSESTree.Parameter): string | null { + if (param.type === 'Identifier') { + return param.name; + } + return null; +} + +const invokeFunctionNames = new Set(['invokeFunction', 'invokeWithinContext']); + +function isInvokeFunctionCall(callee: TSESTree.Expression): boolean { + // object.invokeFunction(...) + if (callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + invokeFunctionNames.has(callee.property.name)) { + return true; + } + // Standalone invokeFunction(...) — unlikely but handle it + if (callee.type === 'Identifier' && invokeFunctionNames.has(callee.name)) { + return true; + } + return false; +} 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..6de65febb8971 --- /dev/null +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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', + 'common.copilottrackingid', +]); + +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-no-unexternalized-strings.ts b/.eslint-plugin-local/code-no-unexternalized-strings.ts index a7065cb2a0db9..c181152ec2ab2 100644 --- a/.eslint-plugin-local/code-no-unexternalized-strings.ts +++ b/.eslint-plugin-local/code-no-unexternalized-strings.ts @@ -62,6 +62,9 @@ export default new class NoUnexternalizedStrings implements eslint.Rule.RuleModu doubleQuotedStringLiterals.delete(keyNode); key = keyNode.value; + } else if (keyNode.type === AST_NODE_TYPES.TemplateLiteral && keyNode.expressions.length === 0 && keyNode.quasis.length === 1) { + key = keyNode.quasis[0].value.cooked ?? undefined; + } else if (keyNode.type === AST_NODE_TYPES.ObjectExpression) { for (const property of keyNode.properties) { if (property.type === AST_NODE_TYPES.Property && !property.computed) { @@ -70,6 +73,9 @@ export default new class NoUnexternalizedStrings implements eslint.Rule.RuleModu doubleQuotedStringLiterals.delete(property.value); key = property.value.value; break; + } else if (property.value.type === AST_NODE_TYPES.TemplateLiteral && property.value.expressions.length === 0 && property.value.quasis.length === 1) { + key = property.value.quasis[0].value.cooked ?? undefined; + break; } } } 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..19bd7cb3c1883 --- /dev/null +++ b/.github/agents/sessions.md @@ -0,0 +1,15 @@ +--- +name: Agents Window Developer +description: Specialist in developing the Agents Window +--- + +# Role and Objective + +You are a developer working on the 'agents window'. Your goal is to make changes to the agents 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 agents 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..8157477868544 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,8 @@ 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. +- Service dependencies MUST be declared in constructors and MUST NOT be accessed through the `IInstantiationService` at any other point in time. ## 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/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md index dc3f187e96c80..ef9dd0066c708 100644 --- a/.github/instructions/sessions.instructions.md +++ b/.github/instructions/sessions.instructions.md @@ -1,11 +1,11 @@ --- -description: Architecture documentation for the Agent Sessions window — a sessions-first app built as a new top-level layer alongside vs/workbench. Covers layout, parts, chat widget, contributions, entry points, and development guidelines. Use when working in `src/vs/sessions` +description: Architecture documentation for the Agents window — an agents-first app built as a new top-level layer alongside vs/workbench. Covers layout, parts, chat widget, contributions, entry points, and development guidelines. Use when working in `src/vs/sessions` applyTo: src/vs/sessions/** --- -# Agent Sessions Window +# Agents Window -The Agent Sessions window is a **standalone application** built as a new top-level layer (`vs/sessions`) in the VS Code architecture. It provides a sessions-first experience optimized for agent workflows — a simplified, fixed-layout workbench where chat is the primary interaction surface and editors appear as modal overlays. +The Agents window is a **standalone application** built as a new top-level layer (`vs/sessions`) in the VS Code architecture. It provides an agents-first experience optimized for agent workflows — a simplified, fixed-layout workbench where chat is the primary interaction surface and editors appear as modal overlays. When working on files under `src/vs/sessions/`, use these skills for detailed guidance: diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md index 1cdc8fa90b4d8..e833fb07e1ef9 100644 --- a/.github/prompts/fix-error.prompt.md +++ b/.github/prompts/fix-error.prompt.md @@ -9,9 +9,52 @@ The user has given you a GitHub issue URL for an unhandled error from the VS Cod Follow the `fix-errors` skill guidelines to fix this error. Key principles: -1. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. -2. **Trace the data flow upward** through the call stack to find the producer of invalid data. -3. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. -4. **If the producer is identifiable**, fix it directly. +1. **Read the error construction code first.** Before proposing any fix, search the codebase for where the error is constructed (the `new Error(...)` or custom error class instantiation). Read the surrounding code to understand: + - What conditions trigger the error (thresholds, validation checks, categorization logic) + - What parameters, classifications, or categories the error encodes + - What the intended meaning of each category is and what action each warrants + - Whether the error is a symptom of invalid data, a threshold-based warning, or a design-time signal + Use this understanding to determine the correct fix strategy. Do NOT assume what the error means from its message alone — the construction code is the source of truth. +2. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. +3. **Trace the data flow upward** through the call stack to find the producer of invalid data. +4. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. +5. **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..40f1cc3a37635 --- /dev/null +++ b/.github/skills/add-policy/SKILL.md @@ -0,0 +1,186 @@ +--- +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 export-policy-data +``` + +This script handles transpilation, sets up `GITHUB_TOKEN` (via `gh` CLI or GitHub OAuth device flow), and runs `--export-policy-data`. The export command reads extension configuration policies from the distro's `product.json` via the GitHub API and merges them into the output. + +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 + +Extension authors cannot add `policy:` fields directly—their settings are defined in the extension's `package.json`, not in VS Code core. Instead, policies for extension settings are defined in `vscode-distro`'s `product.json` under the `extensionConfigurationPolicy` key. + +### How it works + +1. **Source of truth**: The `extensionConfigurationPolicy` map lives in `vscode-distro` under `mixin/{quality}/product.json` (stable, insider, exploration). +2. **Runtime**: When VS Code starts with a distro-mixed `product.json`, `configurationExtensionPoint.ts` reads `extensionConfigurationPolicy` and attaches matching `policy` objects to extension-contributed configuration properties. +3. **Export/build**: The `--export-policy-data` command fetches the distro's `product.json` at the commit pinned in `package.json` and merges extension policies into the output. Use `npm run export-policy-data` which sets up authentication automatically. + +### Distro format + +Each entry in `extensionConfigurationPolicy` must include: + +```json +"extensionConfigurationPolicy": { + "publisher.extension.settingName": { + "name": "PolicyName", + "category": "InteractiveSession", + "minimumVersion": "1.99", + "description": "Human-readable description." + } +} +``` + +- `name`: PascalCase policy name, unique across all policies +- `category`: Must be a valid `PolicyCategory` enum value (e.g., `InteractiveSession`, `Extensions`) +- `minimumVersion`: The VS Code version that first shipped this policy +- `description`: Human-readable description string used to generate localization key/value pairs for ADMX/ADML/macOS/Linux policy artifacts + +### Adding a new extension policy + +1. Add the entry to `extensionConfigurationPolicy` in **all three** quality `product.json` files in `vscode-distro` (`mixin/stable/`, `mixin/insider/`, `mixin/exploration/`) +2. Update the `distro` commit hash in `package.json` to point to the distro commit that includes your new entry — the export command fetches extension policies from the pinned distro commit +3. Regenerate `policyData.jsonc` by running `npm run export-policy-data` (see Step 4 above) +4. Update the test fixture at `src/vs/workbench/contrib/policyExport/test/node/extensionPolicyFixture.json` with the new entry + +### Test fixtures + +The file `src/vs/workbench/contrib/policyExport/test/node/extensionPolicyFixture.json` is a test fixture that must stay in sync with the extension policies in the checked-in `policyData.jsonc`. When extension policies are added or changed in the distro, this fixture must be updated to match — otherwise the integration test will fail because the test output (generated from the fixture) won't match the checked-in file (generated from the real distro). + +### Downstream consumers + +| Consumer | What it reads | Output | +|----------|--------------|--------| +| `policyGenerator.ts` | `policyData.jsonc` | ADMX/ADML (Windows GP), `.mobileconfig` (macOS), `policy.json` (Linux) | +| `vscode-website` (`gulpfile.policies.js`) | `policyData.jsonc` | Enterprise policy reference table at code.visualstudio.com/docs/enterprise/policies | +| `vscode-docs` | Generated from website build | `docs/enterprise/policies.md` | + +## 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..c9bd30c582434 100644 --- a/.github/skills/agent-sessions-layout/SKILL.md +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -1,13 +1,13 @@ --- name: agent-sessions-layout -description: Agent Sessions workbench layout — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements. Use when implementing features or fixing issues in the Agent Sessions workbench layout. +description: Agents workbench layout — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements. Use when implementing features or fixing issues in the Agents workbench layout. --- -When working on the Agent Sessions workbench layout, always follow these guidelines: +When working on the Agents workbench layout, always follow these guidelines: ## 1. Read the Specification First -The authoritative specification for the Agent Sessions layout lives at: +The authoritative specification for the Agents layout lives at: **`src/vs/sessions/LAYOUT.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/menus.ts` | Agents 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/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | -| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `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 agent picker | +| `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..ed3cf72b74193 --- /dev/null +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -0,0 +1,197 @@ +--- +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. +- **`IExternalCustomizationItemProvider`** / **`IExternalCustomizationItem`** — internal interfaces (in `customizationHarnessService.ts`) for extension-contributed providers that supply items directly. These mirror the proposed extension API types. + +Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. + +## Extension API (`chatSessionCustomizationProvider`) + +The proposed API in `src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts` lets extensions register customization providers. Changes to `IExternalCustomizationItem` or `IExternalCustomizationItemProvider` must be kept in sync across the full chain: + +| Layer | File | Type | +|-------|------|------| +| Extension API | `vscode.proposed.chatSessionCustomizationProvider.d.ts` | `ChatSessionCustomizationItem` | +| IPC DTO | `extHost.protocol.ts` | `IChatSessionCustomizationItemDto` | +| ExtHost mapping | `extHostChatAgents2.ts` | `$provideChatSessionCustomizations()` | +| MainThread mapping | `mainThreadChatAgents2.ts` | `provideChatSessionCustomizations` callback | +| Internal interface | `customizationHarnessService.ts` | `IExternalCustomizationItem` | + +When adding fields to `IExternalCustomizationItem`, update all five layers. The proposed API `.d.ts` is additive-only (new optional fields are backward-compatible and do not require a version bump). + +## 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..5672a0a58110b --- /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 server: run the **Component Explorer Server** 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/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md index 7a03d11ee0c4c..f2afd203619d0 100644 --- a/.github/skills/fix-errors/SKILL.md +++ b/.github/skills/fix-errors/SKILL.md @@ -62,6 +62,34 @@ throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.s **Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. +## Understanding error construction before fixing + +Before proposing any fix, **always find and read the code that constructs the error**. Search the codebase for the error class name or a unique substring of the error message. The construction code reveals: + +- **What conditions trigger the error** — thresholds, validation checks, state assertions +- **What classifications or categories the error encodes** — the error may have subtypes that require different fix strategies +- **What the error's parameters mean** — numeric values, ratios, or flags embedded in the message often encode diagnostic context +- **Whether the error is actionable** — some errors are threshold-based warnings where the threshold may be legitimately exceeded by design + +Use this understanding to determine the correct fix strategy. The construction code is the source of truth — do NOT assume what the error means from its message alone. + +### Example: Listener leak errors + +Searching for `ListenerLeakError` leads to `src/vs/base/common/event.ts`, where the construction code reveals: + +```typescript +const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; +const error = new ListenerLeakError(kind, message, topStack); +``` + +Reading this code tells you: +- The error has two categories based on a ratio +- **Dominated** (ratio > 30%): one code path accounts for most listeners → that code path is the problem, fix its disposal +- **Popular** (ratio ≤ 30%): many diverse code paths each contribute a few listeners → the identified stack trace is NOT the root cause; it's just the most identical stack among many. Investigate the emitter and its aggregate subscribers instead +- For popular leaks: do NOT remove caching/pooling/reuse patterns that appear in the top stack — they exist to solve other problems. If the aggregate count is by design (e.g., many menus subscribing to a shared context key service), close the issue as "not planned" + +This analysis came from reading the construction code, not from memorized rules about listener leaks. + ## Guidelines - Prefer enriching error messages over adding try/catch guards diff --git a/.github/skills/integration-tests/SKILL.md b/.github/skills/integration-tests/SKILL.md new file mode 100644 index 0000000000000..e8428fac8986b --- /dev/null +++ b/.github/skills/integration-tests/SKILL.md @@ -0,0 +1,122 @@ +--- +name: integration-tests +description: Use when running integration tests in the VS Code repo. Covers scripts/test-integration.sh (macOS/Linux) and scripts/test-integration.bat (Windows), their supported arguments for filtering, and the difference between node.js integration tests and extension host tests. +--- + +# Running Integration Tests + +Integration tests in VS Code are split into two categories: + +1. **Node.js integration tests** - files ending in `.integrationTest.ts` under `src/`. These run in Electron via the same Mocha runner as unit tests. +2. **Extension host tests** - tests embedded in built-in extensions under `extensions/` (API tests, Git tests, TypeScript tests, etc.). These launch a full VS Code instance with `--extensionDevelopmentPath`. + +## Scripts + +- **macOS / Linux:** `./scripts/test-integration.sh [options]` +- **Windows:** `.\scripts\test-integration.bat [options]` + +When run **without filters**, both scripts execute all node.js integration tests followed by all extension host tests. + +When run **with `--run` or `--runGlob`** (without `--suite`), only the node.js integration tests are run and the filter is applied. Extension host tests are skipped since these filters are node.js-specific. + +When run **with `--grep` alone** (no `--run`, `--runGlob`, or `--suite`), all tests are run -- both node.js integration tests and all extension host suites -- with the grep pattern forwarded to every test runner. + +When run **with `--suite`**, only the matching extension host test suites are run. Node.js integration tests are skipped. Combine `--suite` with `--grep` to filter individual tests within the selected suites. + +## Options + +### `--run ` - Run tests from a specific file + +Accepts a **source file path** (starting with `src/`). Works identically to `scripts/test.sh --run`. + +```bash +./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts +``` + +### `--runGlob ` (aliases: `--glob`, `--runGrep`) - Select test files by path + +Selects which test **files** to load by matching compiled `.js` file paths against a glob pattern. Overrides the default `**/*.integrationTest.js` glob. Only applies to node.js integration tests (extension host tests are skipped). + +```bash +./scripts/test-integration.sh --runGlob "**/search/**/*.integrationTest.js" +``` + +### `--grep ` (aliases: `-g`, `-f`) - Filter test cases by name + +Filters which **test cases** run by matching against their test titles (e.g. `describe`/`test` names). When used alone, the grep is applied to both node.js integration tests and all extension host suites. When combined with `--suite`, only the matched suites run with the grep. + +```bash +./scripts/test-integration.sh --grep "TextSearchProvider" +``` + +### `--suite ` - Run specific extension host test suites + +Runs only the extension host test suites whose name matches the pattern. Supports comma-separated values and shell glob patterns (on macOS/Linux). Node.js integration tests are skipped. + +Available suite names: `api-folder`, `api-workspace`, `colorize`, `terminal-suggest`, `typescript`, `markdown`, `emmet`, `git`, `git-base`, `ipynb`, `notebook-renderers`, `configuration-editing`, `github-authentication`, `css`, `html`. + +```bash +# Run only Git extension tests +./scripts/test-integration.sh --suite git + +# Run API folder and workspace tests (glob, macOS/Linux only) +./scripts/test-integration.sh --suite 'api*' + +# Run multiple specific suites +./scripts/test-integration.sh --suite 'git,emmet,typescript' + +# Filter tests within a suite by name +./scripts/test-integration.sh --suite api-folder --grep 'should open' +``` + +### `--help`, `-h` - Show help + +```bash +./scripts/test-integration.sh --help +``` + +### Other options + +All other options (e.g. `--timeout`, `--coverage`, `--reporter`) are forwarded to the underlying `scripts/test.sh` runner for node.js integration tests. These extra options are **not** forwarded to extension host suites when using `--suite`. + +## Examples + +```bash +# Run all integration tests (node.js + extension host) +./scripts/test-integration.sh + +# Run a single integration test file +./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts + +# Run integration tests matching a grep pattern +./scripts/test-integration.sh --grep "TextSearchProvider" + +# Run integration tests under a specific area +./scripts/test-integration.sh --runGlob "**/workbench/**/*.integrationTest.js" + +# Run only Git extension host tests +./scripts/test-integration.sh --suite git + +# Run API folder + workspace extension tests (glob) +./scripts/test-integration.sh --suite 'api*' + +# Run multiple extension test suites +./scripts/test-integration.sh --suite 'git,typescript,emmet' + +# Grep for specific tests in the API folder suite +./scripts/test-integration.sh --suite api-folder --grep 'should open' + +# Combine file and grep +./scripts/test-integration.sh --run src/vs/workbench/services/search/test/browser/search.integrationTest.ts --grep "should search" +``` + +## 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. + +## Distinction from unit tests + +- **Unit tests** (`.test.ts`) → use `scripts/test.sh` or the `runTests` tool +- **Integration tests** (`.integrationTest.ts` and extension tests) → use `scripts/test-integration.sh` + +Do **not** mix these up: `scripts/test.sh` will not find integration test files unless you explicitly pass `--runGlob **/*.integrationTest.js`, and `scripts/test-integration.sh` is not intended for `.test.ts` files. diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index fc49548b7a384..87669ba6a193f 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -1,9 +1,9 @@ --- name: sessions -description: Agent Sessions window architecture — covers the sessions-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agent Sessions window. +description: Agents window architecture — covers the agents-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agents window. --- -When working on the Agent Sessions window (`src/vs/sessions/`), always follow these guidelines: +When working on the Agents window (`src/vs/sessions/`), always follow these guidelines: ## 1. Read the Specification Documents First @@ -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. @@ -43,13 +41,13 @@ vs/sessions ← Agent Sessions window (this layer) ### 2.3 How It Differs from VS Code -| Aspect | VS Code Workbench | Agent Sessions Window | +| Aspect | VS Code Workbench | Agents Window | |--------|-------------------|----------------------| | Layout | Configurable part positions | Fixed layout, no settings customization | | Chrome | Activity bar, status bar, banner | Simplified — none of these | | Primary UX | Editor-centric | Chat-first (Chat Bar is a primary part) | | Editors | In the grid layout | Modal overlay above the workbench | -| Titlebar | Menubar, editor actions, layout controls | Session picker, run script, toggle sidebar/panel | +| Titlebar | Menubar, editor actions, layout controls | Agent picker, run script, toggle sidebar/panel | | Navigation | Activity bar with viewlets | Sidebar (views) + sidebar footer (account) | | Entry point | `vs/workbench` workbench class | `vs/sessions/browser/workbench.ts` `Workbench` class | @@ -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 @@ -143,7 +154,7 @@ The main editor part is hidden (`display:none`). All editors open via `MODAL_GRO ## 5. Chat Widget -The Agent Sessions chat experience is built around `AgentSessionsChatWidget` — a wrapper around the core `ChatWidget` that adds: +The Agents chat experience is built around `AgentSessionsChatWidget` — a wrapper around the core `ChatWidget` that adds: - **Deferred session creation** — the UI is interactive before any session resource exists; sessions are created on first message send - **Target configuration** — observable state tracking which agent provider (Local, Cloud) is selected @@ -161,22 +172,25 @@ Read `browser/widget/AGENTS_CHAT_WIDGET.md` for the full architecture. ## 6. Menus -The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts` via the `Menus` export. **Never use shared `MenuId.*` constants** from `vs/platform/actions` for agent sessions UI — use the `Menus.*` equivalents instead. +The agents window uses **its own menu IDs** defined in `browser/menus.ts` via the `Menus` export. **Never use shared `MenuId.*` constants** from `vs/platform/actions` for agents window UI — use the `Menus.*` equivalents instead. | 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.CommandCenter` | Center toolbar with session picker widget | -| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.ChatBarTitle` | Chat bar title actions | +| `Menus.CommandCenter` | Center toolbar with agent picker widget | +| `Menus.CommandCenterCenter` | Center section of command center | +| `Menus.TitleBarContext` | Titlebar context menu | +| `Menus.TitleBarLeftLayout` | Left layout toolbar | +| `Menus.TitleBarSessionTitle` | Agent title in titlebar | +| `Menus.TitleBarSessionMenu` | Agent 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 agents 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). @@ -196,40 +210,51 @@ Feature contributions live under `contrib//browser/` and are regist | Contribution | Location | Purpose | |-------------|----------|---------| -| **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 | +| **Sessions View** | `contrib/sessions/browser/` | Agents list in sidebar, agent picker, active session service | +| **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Agent 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 -The agent sessions window registers its own implementations for: +The agents window registers its own implementations for: - `IPaneCompositePartService` → `AgenticPaneCompositePartService` (creates agent-specific parts) - `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. +Views and contributions that should only appear in the agents window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. ## 9. Entry Points | 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..5f2ebfc304ddc --- /dev/null +++ b/.github/skills/unit-tests/SKILL.md @@ -0,0 +1,105 @@ +--- +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 + +#### Bare file paths - Run tests from specific files + +Pass source file paths directly as positional arguments. The test runner automatically treats bare `.ts`/`.js` positional arguments as `--run` values. + +```bash +./scripts/test.sh src/vs/editor/test/common/model.test.ts +``` + +```bat +.\scripts\test.bat src\vs\editor\test\common\model.test.ts +``` + +Multiple files: + +```bash +./scripts/test.sh src/vs/editor/test/common/model.test.ts src/vs/editor/test/common/range.test.ts +``` + +#### `--run ` - Run tests from a specific file (explicit form) + +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. See the `integration-tests` skill for details. + +### 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/component-fixture-tests.yml b/.github/workflows/component-fixture-tests.yml new file mode 100644 index 0000000000000..cdd00061ead80 --- /dev/null +++ b/.github/workflows/component-fixture-tests.yml @@ -0,0 +1,66 @@ +name: Component Fixture Tests + +on: + push: + branches: [main] + pull_request: + branches: + - main + - 'release/*' + +permissions: + contents: read + +concurrency: + group: component-fixture-tests-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + component-fixture-tests: + name: Component Fixture 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 dependencies + run: npm ci --ignore-scripts + 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: Install rspack dependencies + run: npm ci + working-directory: build/rspack + + - name: Transpile source + run: npm run transpile-client + + - name: Install Playwright test dependencies + run: npm ci + working-directory: test/componentFixtures/playwright + + - name: Install Playwright Chromium + run: npx playwright install chromium + working-directory: test/componentFixtures/playwright + + - name: Run Playwright tests + run: npx playwright test + working-directory: test/componentFixtures/playwright + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-test-results + path: test/componentFixtures/playwright/test-results/ diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml index 45d1ae55f623b..954d8dbf0c504 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 + - uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.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-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index 003e1344fb6c7..70874649dd51d 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -11,7 +11,7 @@ on: jobs: linux-cli-test: name: ${{ inputs.job_name }} - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 env: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index df6ab20e586b5..c2d20a7d21d60 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -17,7 +17,7 @@ on: jobs: linux-test: name: ${{ inputs.job_name }} - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 @@ -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-node-modules.yml b/.github/workflows/pr-node-modules.yml index 68e65fd129815..952938c0df4cf 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -10,7 +10,7 @@ permissions: {} jobs: compile: name: Compile - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -86,7 +86,7 @@ jobs: linux: name: Linux - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 env: NPM_ARCH: x64 VSCODE_ARCH: x64 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index bd4a62d42fa26..c9ac6ef037427 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -17,7 +17,7 @@ on: jobs: windows-test: name: ${{ inputs.job_name }} - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + runs-on: windows-2022 env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 @@ -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..c470f35e06938 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' @@ -18,7 +19,7 @@ env: jobs: compile: name: Compile & Hygiene - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -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..c7b5935e2b829 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 @@ -43,90 +42,100 @@ jobs: run: npm ci working-directory: build - - name: Install build/vite dependencies - run: rm -f package-lock.json && npm install - working-directory: build/vite + - name: Install rspack dependencies + run: npm ci + working-directory: build/rspack - - name: Build vite - run: npm run build - working-directory: build/vite + - name: Transpile source + run: npm run transpile-client - name: Install Playwright Chromium 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 render --project ./test/componentFixtures/component-explorer.json - - name: Compare screenshots - id: compare - run: | - npx component-explorer screenshot:compare \ - --project ./test/componentFixtures \ - --report ./test/componentFixtures/.screenshots/report - continue-on-error: true + - name: Upload screenshots + uses: actions/upload-artifact@v7 + with: + name: screenshots + path: test/componentFixtures/.screenshots/current/ - - name: Prepare explorer artifact + - name: Trigger ingestion run: | - mkdir -p /tmp/explorer-artifact/screenshot-report - cp -r build/vite/dist/* /tmp/explorer-artifact/ - if [ -d test/componentFixtures/.screenshots/report ]; then - cp -r test/componentFixtures/.screenshots/report/* /tmp/explorer-artifact/screenshot-report/ - fi - - - name: Upload explorer artifact - uses: actions/upload-artifact@v4 - with: - name: component-explorer - path: /tmp/explorer-artifact/ + curl -sS -X POST "https://hediet-screenshots.azurewebsites.net/ingest" \ + -H "Content-Type: application/json" \ + -d '{ + "owner": "${{ github.repository_owner }}", + "repo": "${{ github.event.repository.name }}", + "runId": ${{ github.run_id }}, + "artifactName": "screenshots" + }' + + # - name: Compare screenshots + # id: compare + # run: | + # ./node_modules/.bin/component-explorer screenshot:compare \ + # --project ./test/componentFixtures \ + # --report ./test/componentFixtures/.screenshots/report + # continue-on-error: true - - name: Upload screenshot report - if: steps.compare.outcome == 'failure' - uses: actions/upload-artifact@v4 - with: - name: screenshot-diff - path: | - test/componentFixtures/.screenshots/current/ - test/componentFixtures/.screenshots/report/ + # - name: Prepare explorer artifact + # run: | + # mkdir -p /tmp/explorer-artifact/screenshot-report + # cp -r build/vite/dist/* /tmp/explorer-artifact/ + # if [ -d test/componentFixtures/.screenshots/report ]; then + # cp -r test/componentFixtures/.screenshots/report/* /tmp/explorer-artifact/screenshot-report/ + # fi - - name: Set check title - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - REPORT="test/componentFixtures/.screenshots/report/report.json" - 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" - else - 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" - - 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: Upload explorer artifact + # 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@v7 + # with: + # name: screenshot-diff + # path: | + # test/componentFixtures/.screenshots/current/ + # test/componentFixtures/.screenshots/report/ + + # - name: Set check title + # env: + # 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" + # 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" + # fi + + # SHA="${{ github.event.pull_request.head.sha || github.sha }}" + # DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json&search=changed" + + # gh api "repos/${{ github.repository }}/statuses/$SHA" \ + # --input - < Daniel Imms Raymond Zhao Tyler Leonhardt Tyler Leonhardt João Moreno João Moreno diff --git a/.npmrc b/.npmrc index b07eade64d573..1030b17d15c34 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.5" +ms_build_id="13703022" 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..228584c8fc47a 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", + "--agents" + ], + "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", @@ -343,6 +397,21 @@ "order": 2 } }, + { + "type": "node", + "request": "launch", + "name": "VS Code Agent Host", + "outputCapture": "std", + "program": "./scripts/code-agent-host.js", + "args": ["--port", "7777", "--without-connection-token"], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "presentation": { + "group": "0_vscode", + "order": 2 + } + }, { "type": "node", "request": "launch", @@ -603,12 +672,22 @@ } }, { - "name": "Component Explorer", + "name": "Component Explorer (Edge)", "type": "msedge", - "port": 9230, "request": "launch", "url": "http://localhost:5337/___explorer", - "preLaunchTask": "Launch Component Explorer", + "preLaunchTask": "Component Explorer Server", + "presentation": { + "group": "1_component_explorer", + "order": 4 + } + }, + { + "name": "Component Explorer (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:5337/___explorer", + "preLaunchTask": "Component Explorer Server", "presentation": { "group": "1_component_explorer", "order": 4 @@ -646,6 +725,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/mcp.json b/.vscode/mcp.json index 713ca79a4afec..9d373e9f11263 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -11,16 +11,17 @@ }, "component-explorer": { "type": "stdio", - "command": "npx", - "cwd": "${workspaceFolder}", + "command": "npm", "args": [ + "exec", + "--no", + "--", "component-explorer", "mcp", - "-c", + "-p", "./test/componentFixtures/component-explorer.json", - "--no-daemon-autostart", - "--no-daemon-hint", - "Start the daemon by running the 'Launch Component Explorer' VS Code task (use the run_task tool). When you start the task, try up to 4 times to give the daemon enough time to start." + "--use-daemon", + "-vv" ] } }, diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index d3f716f5749cb..764b776711946 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.114.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..41638e07ae08c 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.114.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..1b0d1190bae91 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,19 +225,39 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inSessions": true + "problemMatcher": [] }, { - "label": "Run Dev Sessions", + "label": "Run Dev Agents", "type": "shell", "command": "./scripts/code.sh", "windows": { "command": ".\\scripts\\code.bat" }, "args": [ - "--sessions" + "--agents", + "--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 Agents - OSS", + "dependsOn": ["Transpile Client", "Run Dev Agents"], + "dependsOrder": "sequence", + "inSessions": true, + "problemMatcher": [] + }, + { + "label": "Run and Compile Code - OSS", + "dependsOn": ["Transpile Client", "Run Dev"], + "dependsOrder": "sequence", "inSessions": true, "problemMatcher": [] }, @@ -373,11 +393,76 @@ ] }, { - "label": "Launch Component Explorer", + "label": "Component Explorer Server", "type": "shell", - "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json", + "command": "npx component-explorer serve -p ./test/componentFixtures/component-explorer.json -vv --kill-if-running", "isBackground": true, - "problemMatcher": [] + "inSessions": true, + "problemMatcher": { + "owner": "component-explorer", + "fileLocation": "absolute", + "pattern": { + "regexp": "^\\s*at\\s+(.+?):(\\d+):(\\d+)\\s*$", + "file": 1, + "line": 2, + "column": 3 + }, + "background": { + "activeOnStart": false, + "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" + }, + "presentation": { + "focus": false, + "reveal": "never" + } + }, + { + "label": "Serve Out (rspack)", + "type": "npm", + "script": "serve-out-rspack", + "isBackground": true, + "problemMatcher": { + "owner": "rspack", + "fileLocation": "absolute", + "pattern": { + "regexp": "^(.+?):(\\d+):(\\d+):\\s+(error|warning)\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*compiling.*", + "endsPattern": ".*compiled.*successfully.*" + } + } + }, + { + "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..c282bb6a2d9f7 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 @@ -1582,7 +2156,7 @@ SOFTWARE. --------------------------------------------------------- -microsoft/vscode-mssql 1.34.0 - MIT +microsoft/vscode-mssql 1.0.0 - MIT https://github.com/microsoft/vscode-mssql ------------------------------------------ START OF 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..7d50f0ee5512b 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -97,6 +97,17 @@ kerberos/src/** kerberos/node_modules/** !kerberos/**/*.node +cpu-features/** + +ssh2/lib/protocol/crypto/binding.gyp +ssh2/lib/protocol/crypto/build/** +ssh2/lib/protocol/crypto/src/** +ssh2/util/** +ssh2/install.js +ssh2/.github/** +ssh2/.eslint* +ssh2/SFTP.md + node-pty/binding.gyp node-pty/build/** node-pty/src/** @@ -188,3 +199,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..7778d30a58c4b 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 @@ -99,23 +117,39 @@ jobs: workingDirectory: $(TEST_DIR) displayName: Compile Sanity Tests + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get Secrets" + inputs: + azureSubscription: vscode + KeyVaultName: vscode-build-secrets + SecretsFilter: "sanity-tests-account,sanity-tests-password" + # 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 + env: + GITHUB_ACCOUNT: $(sanity-tests-account) + GITHUB_PASSWORD: $(sanity-tests-password) # 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 + env: + GITHUB_ACCOUNT: $(sanity-tests-account) + GITHUB_PASSWORD: $(sanity-tests-password) # 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 + env: + GITHUB_ACCOUNT: $(sanity-tests-account) + GITHUB_PASSWORD: $(sanity-tests-password) # Linux Docker container - ${{ if ne(parameters.container, '') }}: @@ -141,10 +175,14 @@ jobs: --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ + --screenshots-dir "/root/screenshots" \ --verbose \ ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests + env: + GITHUB_ACCOUNT: $(sanity-tests-account) + GITHUB_PASSWORD: $(sanity-tests-password) - bash: | mkdir -p "$(DOCKER_CACHE_DIR)" @@ -152,6 +190,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/distro/mixin-quality.ts b/build/azure-pipelines/distro/mixin-quality.ts index c8ed6886b798e..d1cc9c92b12b8 100644 --- a/build/azure-pipelines/distro/mixin-quality.ts +++ b/build/azure-pipelines/distro/mixin-quality.ts @@ -48,9 +48,8 @@ function main() { let builtInExtensions = oss.builtInExtensions; if (Array.isArray(distro.builtInExtensions)) { - log('Overwriting built-in extensions:', distro.builtInExtensions.map(e => e.name)); + throw new Error('Unexpected builtInExtensions array, expected object with include/exclude or array of extensions'); - builtInExtensions = distro.builtInExtensions; } else if (distro.builtInExtensions) { const include = distro.builtInExtensions['include'] ?? []; const exclude = distro.builtInExtensions['exclude'] ?? []; 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/checker/tsconfig.node.json b/build/checker/tsconfig.node.json index 4fe5c10623d65..b47864a4dd6fb 100644 --- a/build/checker/tsconfig.node.json +++ b/build/checker/tsconfig.node.json @@ -22,6 +22,7 @@ ], "exclude": [ "../../src/**/test/**", - "../../src/**/fixtures/**" + "../../src/**/fixtures/**", + "../../src/typings/editContext.d.ts" ] } diff --git a/build/checker/tsconfig.worker.json b/build/checker/tsconfig.worker.json index 39d3a58453270..b6d6d4dd65a9a 100644 --- a/build/checker/tsconfig.worker.json +++ b/build/checker/tsconfig.worker.json @@ -23,6 +23,7 @@ ], "exclude": [ "../../src/**/test/**", - "../../src/**/fixtures/**" + "../../src/**/fixtures/**", + "../../src/typings/editContext.d.ts" ] } diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 3df57a48a97d2..b1a8c5ad2f900 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 +6441cb87d6c90e2371b7ddbc97f5985d120d320806efa31f147d1efc4e218f18 *chromedriver-v39.8.5-darwin-arm64.zip +2a64fee49920ba1f49bb803ffd7e0434ba619c51633817c926b3ad13122f5318 *chromedriver-v39.8.5-darwin-x64.zip +14502c0bbe15c43f2f4fa6b19c4f2d65a1bc80fc4d446751ad74d52a1f6462e8 *chromedriver-v39.8.5-linux-arm64.zip +adb219dc60834c475a538a7362270069de3e41c1a8c67f53826f3590d208f611 *chromedriver-v39.8.5-linux-armv7l.zip +d126f6b09f6a52921589af076e2a6d141500916af8be28323bddfda89a30d4ab *chromedriver-v39.8.5-linux-x64.zip +d40009137af99e0d36e2fabc3c3cd111ee427bfcc7c9c61c236ad6d69fd349d4 *chromedriver-v39.8.5-mas-arm64.zip +f4f77e87952a843bd97f2c8c957c506f68ae6c1c5141957a7b4ad1ac088c0dde *chromedriver-v39.8.5-mas-x64.zip +c391a3a26d1a078ea67576076d5994c26bad0b86f571aaac397e81f634041ccd *chromedriver-v39.8.5-win32-arm64.zip +8895e250359bb152f1e6525a9655521ba71fd4eb148127ac52c470a3828d431f *chromedriver-v39.8.5-win32-ia32.zip +b34958b53c9a1b2e9ba03e31ad612b7386269b41d562d54b5559d026209f9309 *chromedriver-v39.8.5-win32-x64.zip +979e63cc78437830414f6eb4d825b499eea7ad9d0a7f5b5fd5f9b8cfa67c6c3e *electron-api.json +14aeda5bd4b2f8b6e5531cb5c71af9d8aff57fd740750ed730eb807b44c5c786 *electron-v39.8.5-darwin-arm64-dsym-snapshot.zip +6d97f6ed2fbf3e97f7360c93b7b733be41e5ca86a3ea7fc3880693e5c2329458 *electron-v39.8.5-darwin-arm64-dsym.zip +e84ab05a4b5b6a98c56ff7ce29f090cfc20fdbf298ab7de996c06f0779f6504d *electron-v39.8.5-darwin-arm64-symbols.zip +51b448f6a4c8d53a5f28e8f2183ee1e894e1e543440a5d1b8a04ae33f5bf8920 *electron-v39.8.5-darwin-arm64.zip +2744cb346770606372332075955d09d4373b53f1124a2963238137f847e9e1e6 *electron-v39.8.5-darwin-x64-dsym-snapshot.zip +a33f27ef64824765620e853269b82a910f8a3aa8b9743af40fc90ba995e11f07 *electron-v39.8.5-darwin-x64-dsym.zip +458dc048fab4269345313525c078945fee854ada68fe128f9f9023299f7ab02c *electron-v39.8.5-darwin-x64-symbols.zip +d85f84e1f6e9088c7cdd0355bc4b62f93152a22759ff99b0e53fc25fb94bfe94 *electron-v39.8.5-darwin-x64.zip +68bdeb6f87b67eca2fd05582350d214200eeb7f9572fe5d4f30a28a92e754121 *electron-v39.8.5-linux-arm64-debug.zip +ff6b21dce38e7276d76bdc22b83ea8840b40a8b1948d1430d9168eb459ba90d3 *electron-v39.8.5-linux-arm64-symbols.zip +af9405705b982dba979809e9e964d12d0a4541239d3459b0cbd4f96186623c34 *electron-v39.8.5-linux-arm64.zip +0919549d1da862aa8c723a33cc577e234b9e677ba690145c637685a3175afb5e *electron-v39.8.5-linux-armv7l-debug.zip +a193012557c7b0b8d71af17a7f5ed83795b602b28c3ba2ae57fba0c08f044c53 *electron-v39.8.5-linux-armv7l-symbols.zip +bc53789fefdb5b05e9de26af64367d5e458f20c0b854231226527c572797a484 *electron-v39.8.5-linux-armv7l.zip +f55e4db40c61b6741ea081ddac674a9ccb121b0ffda9121442d47d4d847bc10e *electron-v39.8.5-linux-x64-debug.zip +3a78174f670458fd0ffe45f8f4a1a836da2887c575eba69ae3af50ea8efcff8a *electron-v39.8.5-linux-x64-symbols.zip +dd5f4b21682e9d031defff525809dc58028521925f42ec9caa5ca6535d1524e7 *electron-v39.8.5-linux-x64.zip +3410d48e14f1507308a8b83da57c19bc383a38a33d817110fa9dd68ad25046f7 *electron-v39.8.5-mas-arm64-dsym-snapshot.zip +bb44b96825125c2a581c072d35dbc766af0933f293a566f66ee408a957be4482 *electron-v39.8.5-mas-arm64-dsym.zip +b1c7e83d30e5790a1ec3ef23765bec7b1ceb70bd133f6a7c9300d4d466b50ff5 *electron-v39.8.5-mas-arm64-symbols.zip +33f98522714a6954b2360a46e4dc30c6e4d5e7143327eb0d77c0e8b5767a5821 *electron-v39.8.5-mas-arm64.zip +1b33649e8472851c195c087a499747eeba9c9df1b4b55015f62da3d265fe77b2 *electron-v39.8.5-mas-x64-dsym-snapshot.zip +d9f4d607c87dfe14d2b0bbce50af07ddd9f6d0f19a4b6fc2e523400a7b47b115 *electron-v39.8.5-mas-x64-dsym.zip +40d40299c95d0b3801a46be7ccc4ce783162f0a6f6b05887d50c8bac5541315d *electron-v39.8.5-mas-x64-symbols.zip +2f9ca4b40d49adc416a39d7abda08d73974fc685b644cec8339d2e43fe170de8 *electron-v39.8.5-mas-x64.zip +8c2cb9f4e325c5e33bde838d1340706a8864f90efbc3fe37faca87b32d4132d8 *electron-v39.8.5-win32-arm64-pdb.zip +bd3b659755bdbfa1b3b1800c34934f950d233ae0343406a3787afe5e7830331b *electron-v39.8.5-win32-arm64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-arm64-toolchain-profile.zip +97f534856e01a7025835fab4ffec702b3aa654ec22bb0e992803e33bb9e40671 *electron-v39.8.5-win32-arm64.zip +d8198af8420e08cfef2b393551f91599bbaa4ded6cf223bc6afcfb8929960efe *electron-v39.8.5-win32-ia32-pdb.zip +2890de447c78762d4d1608d5607decb1b837e24c3d51131bf759ff49106b1d63 *electron-v39.8.5-win32-ia32-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-ia32-toolchain-profile.zip +80ae355c419a6c0a64072710cd4147c426e840e5595529ff3713680b6b0c3656 *electron-v39.8.5-win32-ia32.zip +94d5215bc025cbc0308712b89292102a80bdb178294f585a940f7074f9e1bf42 *electron-v39.8.5-win32-x64-pdb.zip +8ec7cb5727a3f95172e927823ed83d7397fc240e7f241711618ae1da8df41c42 *electron-v39.8.5-win32-x64-symbols.zip +86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.5-win32-x64-toolchain-profile.zip +d75c0057fd58c08023ff82ed9dd38443f90b4a962c9a9359aa74d9070f4add34 *electron-v39.8.5-win32-x64.zip +3ecbec0c125266a5813efeabd010b4dd0b439adfd6ad7372e662f0ab9effa6e1 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.5-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.5-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.5-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.5-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.5-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.5-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.5-mas-x64.zip +71d478432519dda32a53c47eb5fcb97d23265273ff7939da54cccf7a19f6f87e *ffmpeg-v39.8.5-win32-arm64.zip +3c52c43a62ebf1ebbe0c02611ad4399eb0131257849b707156417ffcbaa144c8 *ffmpeg-v39.8.5-win32-ia32.zip +a83ee4e4c09eb741942d7421822a58e6ad167cd0085a5470679641f1f89f4ddb *ffmpeg-v39.8.5-win32-x64.zip +ab36c3b4b42994616f93acb84337c94b9c8622b4e568f148eb8c7eb292e35acb *hunspell_dictionaries.zip +6104e4446cd246d953026379f8cd46dc56c109efb35d46c466e814de237b9999 *libcxx-objects-v39.8.5-linux-arm64.zip +dcebd0c67d70010a66558d82d53d33b642b3016dd0fbf305aa6f13d95ebafdfc *libcxx-objects-v39.8.5-linux-armv7l.zip +0831658f5b7feee6ded0d811e738ca191021e228696a07b3a615aaa7dd376b85 *libcxx-objects-v39.8.5-linux-x64.zip +8e22df064ecc116052d2518596a3391ea0a275a2907931b50b46968815bb6499 *libcxx_headers.zip +edf24979ef27199ad5cd9de9b52a56ef9bad022c7d9daff59c81a873bf0185cc *libcxxabi_headers.zip +d6d8535cbd733e6b2dfffc22d06030ff795d2981b11c1f66658e98919246a4ef *mksnapshot-v39.8.5-darwin-arm64.zip +c10436ad7436dda07f5d42a5664389a3a5fbb25a49693e21cc4aa57713a0df73 *mksnapshot-v39.8.5-darwin-x64.zip +909ca6e3c6b701b3ec8a6bdd33ee8825534fbd224fa3977763d9dcbba0311da9 *mksnapshot-v39.8.5-linux-arm64-x64.zip +2d78e66afccb846fe0b0709265a85fc3af2243beb846b6b76dc5b61f4e41cc59 *mksnapshot-v39.8.5-linux-armv7l-x64.zip +9a9d9059854fd703e68d14c625e56e8d9ee22c882bbaaff28f76f960e99aff3f *mksnapshot-v39.8.5-linux-x64.zip +90645c495177ec4a9ef531d2d305cf78a5d1f7d740aabb272d09b4489a05d1ff *mksnapshot-v39.8.5-mas-arm64.zip +e8f3abd074b97e62f7692c5701355c54d9e2eca6ab75d441b61c31a4d7bb7c01 *mksnapshot-v39.8.5-mas-x64.zip +b76318111bcbf976651583bddc20f69ecc502eb1f2a9494273f96e3c026c2f40 *mksnapshot-v39.8.5-win32-arm64-x64.zip +51ccb7b7b42345edff911d0bde33c8aafc2fb4bd1ce83accc48bfd28170c24f3 *mksnapshot-v39.8.5-win32-ia32.zip +06d773593a664bc3c55a7ad94edefe40da32394c39962b8745a7e3864432b2ff *mksnapshot-v39.8.5-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.linux.ts b/build/gulpfile.vscode.linux.ts index c5d216319ce07..5d2633b5766cc 100644 --- a/build/gulpfile.vscode.linux.ts +++ b/build/gulpfile.vscode.linux.ts @@ -15,6 +15,7 @@ import packageJson from '../package.json' with { type: 'json' }; import product from '../product.json' with { type: 'json' }; import { getDependencies } from './linux/dependencies-generator.ts'; import { recommendedDeps as debianRecommendedDependencies } from './linux/debian/dep-lists.ts'; +import { recommendedDeps as rpmRecommendedDependencies } from './linux/rpm/dep-lists.ts'; import * as path from 'path'; import * as cp from 'child_process'; import { promisify } from 'util'; @@ -202,6 +203,7 @@ function prepareRpmPackage(arch: string) { .pipe(replace('@@QUALITY@@', (product as typeof product & { quality?: string }).quality || '@@QUALITY@@')) .pipe(replace('@@UPDATEURL@@', (product as typeof product & { updateUrl?: string }).updateUrl || '@@UPDATEURL@@')) .pipe(replace('@@DEPENDENCIES@@', dependencies.join(', '))) + .pipe(replace('@@RECOMMENDS@@', rpmRecommendedDependencies.join(', '))) .pipe(replace('@@STRIP@@', stripBinary)) .pipe(rename('SPECS/' + product.applicationName + '.spec')); diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c50bdfcda3f7c..4113d6dd54b54 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,9 @@ const vscodeResourceIncludes = [ // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + 'out-build/vs/sessions/contrib/welcome/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 +240,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 +250,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 +274,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 +326,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 +356,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,13 +395,13 @@ 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 + const packageSubJsonStream = embedded ? gulp.src(['package.json'], { base: '.' }) .pipe(jsonEditor((json: Record) => { - json.name = `sessions-${quality || 'oss-dev'}`; + json.name = embedded.nameShort; return json; })) .pipe(rename('package.sub.json')) @@ -404,12 +410,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 +430,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 +510,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}`)); @@ -522,7 +535,15 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d ...(embedded ? { darwinMiniAppName: embedded.nameShort, darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, - darwinMiniAppIcon: 'resources/darwin/sessions.icns', + darwinMiniAppIcon: 'resources/darwin/agents.icns', + darwinMiniAppAssetsCar: 'resources/darwin/agents.car', + darwinMiniAppBundleURLTypes: [{ + role: 'Viewer', + name: embedded.nameLong, + urlSchemes: [embedded.urlProtocol] + }], + win32ProxyAppName: embedded.nameShort, + win32ProxyIcon: 'resources/win32/sessions.ico', } : {}) }; @@ -531,7 +552,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 +573,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 +606,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 +651,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 +683,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 +722,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 +737,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..b5d4822a6ddd8 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); @@ -79,7 +80,6 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { const productJsonPath = path.join(outputPath, 'product.json'); const productJson = JSON.parse(fs.readFileSync(originalProductJsonPath, 'utf8')); productJson['target'] = target; - fs.writeFileSync(productJsonPath, JSON.stringify(productJson, undefined, '\t')); const definitions: Record = { NameLong: product.nameLong, @@ -112,12 +112,32 @@ 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) { + // VS Code's sibling is the embedded app. + productJson['win32SiblingExeBasename'] = embedded.nameShort; + // The embedded app's sibling is VS Code. + if (productJson['embedded']) { + productJson['embedded']['win32SiblingExeBasename'] = product.nameShort; + } + 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`; definitions['AppxPackageName'] = `${product.win32AppUserModelId}`; } + fs.writeFileSync(productJsonPath, JSON.stringify(productJson, undefined, '\t')); + packageInnoSetup(issPath, { definitions }, cb as (err?: Error | null) => void); }; } 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..acfe9dfdddd5f --- /dev/null +++ b/build/lib/embeddedType.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + win32SiblingExeBasename?: string; +}; 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..7c7d34c412e37 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,136 @@ { "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/localAgentHost", + "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/exportPolicyData.ts b/build/lib/policies/exportPolicyData.ts new file mode 100644 index 0000000000000..eaaaeb60cb69d --- /dev/null +++ b/build/lib/policies/exportPolicyData.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { execSync, execFileSync } from 'child_process'; +import { resolve } from 'path'; + +const rootPath = resolve(import.meta.dirname, '..', '..', '..'); + +// VS Code OAuth app client ID (same as the GitHub Authentication extension) +const CLIENT_ID = '01ab8ac9400c4e429b23'; + +/** + * Acquires a GitHub token via the OAuth device flow. + * Opens the browser for the user to authorize, then polls for the token. + */ +async function acquireTokenViaDeviceFlow(): Promise { + const response1 = await (await fetch('https://github.com/login/device/code', { + method: 'POST', + body: JSON.stringify({ client_id: CLIENT_ID, scope: 'repo' }), + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + })).json() as { user_code: string; device_code: string; verification_uri: string; expires_in: number; interval: number }; + + console.log(`\n Copy this code: ${response1.user_code}`); + console.log(` Then open: ${response1.verification_uri}`); + console.log(` Waiting for authorization (up to ${response1.expires_in}s)...\n`); + + let expiresIn = response1.expires_in; + while (expiresIn > 0) { + await new Promise(resolve => setTimeout(resolve, 1000 * response1.interval)); + expiresIn -= response1.interval; + + const response2 = await (await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: response1.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + })).json() as { access_token?: string }; + + if (response2.access_token) { + return response2.access_token; + } + } + + throw new Error('Timed out waiting for GitHub authorization'); +} + +// Ensure sources are transpiled +console.log('Transpiling client sources...'); +execSync('npm run transpile-client', { cwd: rootPath, stdio: 'inherit' }); + +// Set up GITHUB_TOKEN if not already set +if (!process.env['GITHUB_TOKEN'] && !process.env['DISTRO_PRODUCT_JSON']) { + // Try gh CLI first (fast, non-interactive) + let token: string | undefined; + try { + token = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); + console.log('Set GITHUB_TOKEN from gh CLI.'); + } catch { + // Fall back to OAuth device flow (interactive) + console.log('gh CLI not available, starting GitHub OAuth device flow...'); + token = await acquireTokenViaDeviceFlow(); + console.log('GitHub authorization successful.'); + } + + process.env['GITHUB_TOKEN'] = token; +} + +// Run the export +console.log('Exporting policy data...'); +const codeScript = process.platform === 'win32' + ? resolve(rootPath, 'scripts', 'code.bat') + : resolve(rootPath, 'scripts', 'code.sh'); + +execSync(`"${codeScript}" --export-policy-data`, { + cwd: rootPath, + stdio: 'inherit', + env: process.env, +}); + +console.log('\nPolicy data exported to build/lib/policies/policyData.jsonc'); diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9c1e1e0e87a8f..84c357c7a453a 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -1,4 +1,4 @@ -/** THIS FILE IS AUTOMATICALLY GENERATED USING `code --export-policy-data`. DO NOT MODIFY IT MANUALLY. **/ +/** THIS FILE IS AUTOMATICALLY GENERATED USING `npm run export-policy-data`. DO NOT MODIFY IT MANUALLY. **/ { "categories": [ { @@ -39,32 +39,34 @@ ], "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": "" + "default": "", + "included": false }, { - "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", - "default": "" + "default": "", + "included": false }, { "key": "extensions.allowed", @@ -78,7 +80,8 @@ } }, "type": "object", - "default": "*" + "default": "*", + "included": true }, { "key": "chat.tools.global.autoApprove", @@ -92,7 +95,8 @@ } }, "type": "boolean", - "default": false + "default": false, + "included": true }, { "key": "chat.tools.eligibleForAutoApproval", @@ -106,7 +110,8 @@ } }, "type": "object", - "default": {} + "default": {}, + "included": true }, { "key": "chat.mcp.access", @@ -139,7 +144,8 @@ "none", "registry", "all" - ] + ], + "included": true }, { "key": "chat.extensionTools.enabled", @@ -153,7 +159,8 @@ } }, "type": "boolean", - "default": true + "default": true, + "included": true }, { "key": "chat.agent.enabled", @@ -167,7 +174,23 @@ } }, "type": "boolean", - "default": true + "default": true, + "included": 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, + "included": true }, { "key": "chat.useHooks", @@ -181,7 +204,8 @@ } }, "type": "boolean", - "default": true + "default": true, + "included": true }, { "key": "chat.tools.terminal.enableAutoApprove", @@ -195,7 +219,8 @@ } }, "type": "boolean", - "default": true + "default": true, + "included": true }, { "key": "update.mode", @@ -233,7 +258,8 @@ "manual", "start", "default" - ] + ], + "included": true }, { "key": "telemetry.telemetryLevel", @@ -271,7 +297,8 @@ "error", "crash", "off" - ] + ], + "included": true }, { "key": "telemetry.feedback.enabled", @@ -285,7 +312,83 @@ } }, "type": "boolean", - "default": true + "default": true, + "included": 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, + "included": true + }, + { + "key": "github.copilot.nextEditSuggestions.enabled", + "name": "CopilotNextEditSuggestions", + "category": "InteractiveSession", + "minimumVersion": "1.99", + "localization": { + "description": { + "key": "github.copilot.nextEditSuggestions.enabled", + "value": "Whether to enable next edit suggestions (NES). NES can propose a next edit based on your recent changes." + } + }, + "type": "boolean", + "default": true, + "included": true + }, + { + "key": "github.copilot.chat.reviewSelection.enabled", + "name": "CopilotReviewSelection", + "category": "InteractiveSession", + "minimumVersion": "1.104", + "localization": { + "description": { + "key": "github.copilot.chat.reviewSelection.enabled", + "value": "Enables code review on current selection." + } + }, + "type": "boolean", + "default": true, + "included": true + }, + { + "key": "github.copilot.chat.reviewAgent.enabled", + "name": "CopilotReviewAgent", + "category": "InteractiveSession", + "minimumVersion": "1.104", + "localization": { + "description": { + "key": "github.copilot.chat.reviewAgent.enabled", + "value": "Enables the code review agent." + } + }, + "type": "boolean", + "default": true, + "included": true + }, + { + "key": "github.copilot.chat.claudeAgent.enabled", + "name": "Claude3PIntegration", + "category": "InteractiveSession", + "minimumVersion": "1.113", + "localization": { + "description": { + "key": "github.copilot.chat.claudeAgent.enabled", + "value": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription." + } + }, + "type": "boolean", + "default": true, + "included": true } ] } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a913a9534fcfc..ead57c9b5d23a 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", @@ -931,6 +941,9 @@ "--notebook-editor-font-weight", "--outline-element-color", "--separator-border", + "--session-bar-background", + "--session-tab-active-foreground", + "--session-tab-inactive-foreground", "--status-border-top-color", "--tab-border-bottom-color", "--tab-border-top-color", @@ -945,6 +958,7 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--update-progress", "--reveal-button-size", "--part-background", "--part-border-color", @@ -968,6 +982,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 +1020,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/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index 46c257da4f7a7..5ad2e946dcca7 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -17,7 +17,9 @@ export const additionalDeps = [ // Dependencies that we can only recommend // for now since some of the older distros don't support them. export const recommendedDeps = [ - 'libvulkan1' // Move to additionalDeps once support for Trusty and Jessie are dropped. + 'libvulkan1', // Move to additionalDeps once support for Trusty and Jessie are dropped. + 'bubblewrap', // agent command sandboxing + 'socat', // agent command sandboxing ]; export const referenceGeneratedDepsByArch = { diff --git a/build/linux/rpm/dep-lists.ts b/build/linux/rpm/dep-lists.ts index 0424c8d37a62d..8d815749cd2b8 100644 --- a/build/linux/rpm/dep-lists.ts +++ b/build/linux/rpm/dep-lists.ts @@ -13,7 +13,12 @@ export const additionalDeps = [ 'rpmlib(FileDigests) <= 4.6.0-1', 'libvulkan.so.1()(64bit)', 'libcurl.so.4()(64bit)', - 'xdg-utils' // OS integration + 'xdg-utils', // OS integration +]; + +export const recommendedDeps = [ + 'bubblewrap', // agent command sandboxing + 'socat', // agent command sandboxing ]; export const referenceGeneratedDepsByArch = { 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..db8df90a3752c 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -11,6 +11,7 @@ import { existsSync } from 'fs'; export const dirs = [ '', 'build', + 'build/rspack', 'build/vite', 'extensions', 'extensions/configuration-editing', @@ -60,6 +61,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..74c28a826cc03 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" @@ -800,9 +800,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -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..cd5b63a14ced2 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,24 @@ if (process.env.npm_execpath?.includes('yarn')) { throw new Error(); } +const npmUserAgent = process.env.npm_config_user_agent; +const npmVersionMatch = npmUserAgent?.match(/npm\/(\d+)\.(\d+)\.(\d+)/); +if (npmVersionMatch) { + const npmMajor = parseInt(npmVersionMatch[1]); + const npmMinor = parseInt(npmVersionMatch[2]); + if (npmMajor > 11 || (npmMajor === 11 && npmMinor >= 2)) { + console.error(`\x1b[1;31m*** Please use npm version < 11.2.0. Currently using v${npmUserAgent}.\x1b[0;0m`); + 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..92f3b6a4a3e70 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.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "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" @@ -2299,10 +2299,11 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -2318,9 +2319,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": { @@ -2590,9 +2591,9 @@ "license": "BSD-2-Clause" }, "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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3493,10 +3494,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 +3523,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" @@ -4471,9 +4490,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -4512,9 +4531,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 +4673,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 +5105,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", @@ -5157,9 +5193,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "devOptional": true, "license": "MIT", "engines": { @@ -6130,9 +6166,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 +6577,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" }, @@ -6707,9 +6743,9 @@ } }, "node_modules/vscode-universal-bundler/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -6743,12 +6779,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/rspack/package-lock.json b/build/rspack/package-lock.json new file mode 100644 index 0000000000000..21924ee8fe5a2 --- /dev/null +++ b/build/rspack/package-lock.json @@ -0,0 +1,4466 @@ +{ + "name": "code-oss-dev-rspack", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-oss-dev-rspack", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@vscode/component-explorer": "^0.2.1-6", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-3" + }, + "devDependencies": { + "@rspack/cli": "^1.3.18", + "@rspack/core": "^1.3.18", + "@vscode/esm-url-webpack-plugin": "^1.0.1-3" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", + "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", + "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", + "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", + "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", + "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", + "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", + "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.1", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", + "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rspack/binding": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.10.tgz", + "integrity": "sha512-j+DPEaSJLRgasxXNpYQpvC7wUkQF5WoWPiTfm4fLczwlAmYwGSVkJiyWDrOlvVPiGGYiXIaXEjVWTw6fT6/vnA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.10", + "@rspack/binding-darwin-x64": "1.7.10", + "@rspack/binding-linux-arm64-gnu": "1.7.10", + "@rspack/binding-linux-arm64-musl": "1.7.10", + "@rspack/binding-linux-x64-gnu": "1.7.10", + "@rspack/binding-linux-x64-musl": "1.7.10", + "@rspack/binding-wasm32-wasi": "1.7.10", + "@rspack/binding-win32-arm64-msvc": "1.7.10", + "@rspack/binding-win32-ia32-msvc": "1.7.10", + "@rspack/binding-win32-x64-msvc": "1.7.10" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.10.tgz", + "integrity": "sha512-bsXi7I6TpH+a4L6okIUh1JDvwT+XcK/L7Yvhu5G2t5YYyd2fl5vMM5O9cePRpEb0RdqJZ3Z8i9WIWHap9aQ8Gw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.10.tgz", + "integrity": "sha512-h/kOGL1bUflDDYnbiUjaRE9kagJpour4FatGihueV03+cRGQ6jpde+BjUakqzMx65CeDbeYI6jAiPhElnlAtRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.10.tgz", + "integrity": "sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.10.tgz", + "integrity": "sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.10.tgz", + "integrity": "sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.10.tgz", + "integrity": "sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.10.tgz", + "integrity": "sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.10.tgz", + "integrity": "sha512-FaQGSCXH89nMOYW0bVp0bKQDQbrOEFFm7yedla7g6mkWlFVQo5UyBxid5wJUCqGJBtJepRxeRfByWiaI5nVGvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.10.tgz", + "integrity": "sha512-/66TNLOeM4R5dHhRWRVbMTgWghgxz+32ym0c/zGGXQRoMbz7210EoL40ALUgdBdeeREO8LoV+Mn7v8/QZCwHzw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.10.tgz", + "integrity": "sha512-SUa3v1W7PGFCy6AHRmDsm43/tkfaZFi1TN2oIk5aCdT9T51baDVBjAbehRDu9xFbK4piL3k7uqIVSIrKgVqk1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/cli": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.10.tgz", + "integrity": "sha512-654U2gprMyuppwiWpzNRiM1HWNFaJpGHSGlEfNwIA1GDZjtJ5S1qcO9uFbptS3kn6Ku1jhZup3IMG+AaaN+QXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.5.7", + "@rspack/dev-server": "~1.1.5", + "exit-hook": "^4.0.0", + "webpack-bundle-analyzer": "4.10.2" + }, + "bin": { + "rspack": "bin/rspack.js" + }, + "peerDependencies": { + "@rspack/core": "^1.0.0-alpha || ^1.x" + } + }, + "node_modules/@rspack/core": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.10.tgz", + "integrity": "sha512-dO7J0aHSa9Fg2kGT0+ZsM500lMdlNIyCHavIaz7dTDn6KXvFz1qbWQ/48x3OlNFw1mA0jxAjjw9e7h3sWQZUNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.10", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/dev-server": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.1.5.tgz", + "integrity": "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "http-proxy-middleware": "^2.0.9", + "p-retry": "^6.2.0", + "webpack-dev-server": "5.2.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@rspack/core": "*" + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "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/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vscode/component-explorer": { + "version": "0.2.1-6", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-6.tgz", + "integrity": "sha512-30yBe83FiBYEcKdF1sn1hAYkv1FGzbtdUMq1lHFVqPZdspuxXORS56yuWIZsvIE1HxOtIdRHWOF/hDqVgCQWFg==", + "license": "MIT", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@vscode/component-explorer-webpack-plugin": { + "version": "0.3.1-3", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-3.tgz", + "integrity": "sha512-cVHnppJ2SFUQ/YHyw0rQZTyhO3/1AI8S+Qa6MTW3mPkea5+Frx390uIQnAZ03BBlVykhKe7aZ6yiljq82QQdNg==", + "license": "MIT", + "dependencies": { + "tinyglobby": "^0.2.0" + }, + "peerDependencies": { + "@vscode/component-explorer": "*" + } + }, + "node_modules/@vscode/esm-url-webpack-plugin": { + "version": "1.0.1-5", + "resolved": "https://registry.npmjs.org/@vscode/esm-url-webpack-plugin/-/esm-url-webpack-plugin-1.0.1-5.tgz", + "integrity": "sha512-XnPSKzCW1Lti9pfc/qAvXwlp7gbFGGJmtXiurzqRrWJS8cm/g87dDoSbh45eioynUSDR6EwCW/kFbKmIR54RhQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "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/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "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-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", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.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", + "peer": true, + "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", + "peer": true, + "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", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exit-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz", + "integrity": "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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-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/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "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": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-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-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/launch-editor": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", + "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.3" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", + "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-to-fsa": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-index": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/spdy-transport/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/spdy-transport/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/spdy/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/spdy/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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", + "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/build/rspack/package.json b/build/rspack/package.json new file mode 100644 index 0000000000000..1a19588282284 --- /dev/null +++ b/build/rspack/package.json @@ -0,0 +1,18 @@ +{ + "name": "code-oss-dev-rspack", + "version": "1.0.0", + "private": true, + "license": "MIT", + "scripts": { + "serve-out": "rspack serve --config rspack.serve-out.config.mts" + }, + "devDependencies": { + "@rspack/cli": "^1.3.18", + "@rspack/core": "^1.3.18", + "@vscode/esm-url-webpack-plugin": "^1.0.1-3" + }, + "dependencies": { + "@vscode/component-explorer": "^0.2.1-6", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-3" + } +} diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts new file mode 100644 index 0000000000000..a4be30d0362ca --- /dev/null +++ b/build/rspack/rspack.serve-out.config.mts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { Configuration } from '@rspack/core'; +import { HtmlRspackPlugin, rspack } from '@rspack/core'; +import { ComponentExplorerPlugin } from '@vscode/component-explorer-webpack-plugin'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import net from 'net'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../..'); + +function findFreePort(startPort: number): Promise { + return new Promise(resolve => { + const server = net.createServer(); + server.listen(startPort, 'localhost', () => { + server.close(() => resolve(startPort)); + }); + server.on('error', () => resolve(findFreePort(startPort + 1))); + }); +} + +const port = await findFreePort(5123); + +export default { + context: repoRoot, + mode: 'development', + target: 'web', + entry: { + workbench: path.join(repoRoot, 'out', 'vs', 'code', 'browser', 'workbench', 'workbench.js'), + }, + output: { + path: path.join(repoRoot, '.build', 'rspack-serve-out'), + filename: 'bundled/[name].js', + chunkFilename: 'bundled/[name].js', + assetModuleFilename: 'bundled/assets/[name][ext][query]', + publicPath: '/', + clean: true, + }, + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + type: 'css', + }, + { + test: /\.ttf$/, + type: 'asset/resource', + }, + ], + }, + plugins: [ + new ComponentExplorerPlugin({ + include: 'out/**/*.fixture.js', + }), + new rspack.NormalModuleReplacementPlugin(/\.css$/, resource => { + if (!resource.request.startsWith('.')) { + return; + } + + const requestedPath = path.resolve(resource.context, resource.request); + const outVsSegment = `${path.sep}out${path.sep}vs${path.sep}`; + const srcVsSegment = `${path.sep}src${path.sep}vs${path.sep}`; + + if (!requestedPath.includes(outVsSegment) || fs.existsSync(requestedPath)) { + return; + } + + const sourceCssPath = requestedPath.replace(outVsSegment, srcVsSegment); + if (sourceCssPath !== requestedPath && fs.existsSync(sourceCssPath)) { + resource.request = sourceCssPath; + } + }), + new HtmlRspackPlugin({ + filename: 'index.html', + template: path.join(__dirname, 'workbench-rspack.html'), + chunks: ['workbench'], + }), + ], + lazyCompilation: false, + devServer: { + host: 'localhost', + port, + hot: 'only', + liveReload: false, + compress: false, + headers: { + 'Access-Control-Allow-Origin': '*', + }, + devMiddleware: { + writeToDisk: false, + }, + static: [ + { + directory: repoRoot, + publicPath: '/', + watch: false, + }, + ], + client: { + overlay: false, + }, + allowedHosts: 'all', + }, + watchOptions: { + // Poll the out/ directory since it's gitignored and may be excluded by default + ignored: /node_modules/, + }, +} satisfies Configuration; diff --git a/build/rspack/workbench-rspack.html b/build/rspack/workbench-rspack.html new file mode 100644 index 0000000000000..58b0ba6bd0949 --- /dev/null +++ b/build/rspack/workbench-rspack.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 4179138e714c7..70e0339f77fa0 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,8 +8,8 @@ "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.2.1-6", + "@vscode/component-explorer-vite-plugin": "^0.2.1-5", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" @@ -315,9 +315,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 +329,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 +343,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 +357,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 +371,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 +385,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 +399,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 +413,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 +427,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 +441,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 +455,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 +469,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 +483,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 +497,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 +511,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 +525,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 +539,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 +553,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 +567,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 +581,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 +595,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 +609,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 +623,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 +637,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 +651,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" ], @@ -683,20 +683,22 @@ "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==", + "version": "0.2.1-6", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-6.tgz", + "integrity": "sha512-30yBe83FiBYEcKdF1sn1hAYkv1FGzbtdUMq1lHFVqPZdspuxXORS56yuWIZsvIE1HxOtIdRHWOF/hDqVgCQWFg==", "dev": true, + "license": "MIT", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.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==", + "version": "0.2.1-5", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.2.1-5.tgz", + "integrity": "sha512-D3Pg5HULyLemvJsbo470uO1oESfZMaHKHHt4OyVvmNsU7MhI7hTEWmzb+yc3CeFwsUC3E7G2uo0sjDmQWzue4g==", "dev": true, + "license": "MIT", "dependencies": { "tinyglobby": "^0.2.0" }, @@ -1066,9 +1068,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1167,9 +1169,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 +1185,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" } }, diff --git a/build/vite/package.json b/build/vite/package.json index 245bf4fc8001a..00c0d88e7b1f7 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.2.1-6", + "@vscode/component-explorer-vite-plugin": "^0.2.1-5", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/build/vite/style.css b/build/vite/style.css index f1ee3ca812f62..e7a03d28dae5b 100644 --- a/build/vite/style.css +++ b/build/vite/style.css @@ -8,8 +8,12 @@ border: 1px solid black; } +/* +Disabled because of rspack + @font-face { font-family: "codicon"; font-display: block; src: url("~@vscode/codicons/dist/codicon.ttf") format("truetype"); } +*/ 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..a8bbe5aac74ae 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.21.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..0d65fcb367d17 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "inno_updater" -version = "0.18.2" +version = "0.21.0" authors = ["Microsoft "] +edition = "2024" build = "build.rs" [dependencies] @@ -9,7 +10,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] @@ -30,3 +31,8 @@ features = [ [profile.release] lto = true panic = 'abort' + +[[bin]] +name = "test_helper" +path = "src/bin/test_helper.rs" +test = false diff --git a/build/win32/code.iss b/build/win32/code.iss index f7091b28e5597..e7bf55036b7c6 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 @@ -1712,13 +1808,13 @@ begin if not SessionEndFileExists() and not CancelFileExists() then begin StopTunnelServiceIfNeeded(); Log('Invoking inno_updater for background update'); - Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); + Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"' {#ifdef ProxyExeBasename} + ' "{#ProxyExeBasename}.exe"' {#endif}), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); DeleteFile(ExpandConstant('{app}\updating_version')); Log('inno_updater completed successfully'); #if "system" == InstallTarget if IsVersionedUpdate() then begin Log('Invoking inno_updater to remove previous installation folder'); - Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); + Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}" "{#ExeBasename}.exe"' {#ifdef ProxyExeBasename} + ' "{#ProxyExeBasename}.exe"' {#endif}), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); Log('inno_updater completed gc successfully'); end; #endif @@ -1728,7 +1824,7 @@ begin end else begin if IsVersionedUpdate() then begin Log('Invoking inno_updater to remove previous installation folder'); - Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); + Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"--gc" "{app}\{#ExeBasename}.exe" "{#VersionedResourcesFolder}" "{#ExeBasename}.exe"' {#ifdef ProxyExeBasename} + ' "{#ProxyExeBasename}.exe"' {#endif}), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); Log('inno_updater completed gc successfully'); end; end; @@ -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..b2522d135a0e9 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..69806e5710ee4 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": "9d2f8cb4da0d35e2daf7e7f60e35313b508cb224", + "tag": "39.8.5" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.6.0" + "version": "39.8.5" }, { "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..a8c70dc533dea 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 @@ -5095,7 +5093,7 @@ DEALINGS IN THE SOFTWARE. num-conv 0.2.0 - MIT OR Apache-2.0 https://github.com/jhpratt/num-conv -Copyright (c) 2023 Jacob Pratt +Copyright (c) Jacob Pratt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -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 @@ -14029,9 +14167,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. --------------------------------------------------------- --------------------------------------------------------- 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..51e7347c4ea5c 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)] @@ -216,11 +220,45 @@ pub struct ServeWebArgs { /// Specifies the directory that server data is kept in. #[clap(long)] pub server_data_dir: Option, + /// The workspace folder to open when no input is specified in the browser URL. + #[clap(long)] + pub default_folder: Option, + /// The workspace to open when no input is specified in the browser URL. + #[clap(long)] + pub default_workspace: Option, + /// Disables telemetry. + #[clap(long)] + pub disable_telemetry: bool, /// Use a specific commit SHA for the client. #[clap(long)] 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/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index d3f7db83691ee..d3a9a88a87dd4 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -813,6 +813,17 @@ impl ConnectionManager { cmd.arg("--connection-token-file"); cmd.arg(ct); } + if let Some(a) = &args.args.default_folder { + cmd.arg("--default-folder"); + cmd.arg(a); + } + if let Some(a) = &args.args.default_workspace { + cmd.arg("--default-workspace"); + cmd.arg(a); + } + if args.args.disable_telemetry { + cmd.arg("--disable-telemetry"); + } // removed, otherwise the workbench will not be usable when running the CLI from sources. cmd.env_remove("VSCODE_DEV"); diff --git a/eslint.config.js b/eslint.config.js index 93a8a1b7b4396..44f6ade2013d6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -88,10 +88,12 @@ export default tseslint.config( 'local/code-must-use-super-dispose': 'warn', 'local/code-declare-service-brand': 'warn', 'local/code-no-reader-after-await': 'warn', + 'local/code-no-accessor-after-await': 'warn', 'local/code-no-observable-get-in-reactive-context': 'warn', '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 +185,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 +336,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 +843,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 +1057,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 +1498,7 @@ export default tseslint.config( // - electron-main 'when': 'hasNode', 'allow': [ + '@github/copilot-sdk', '@parcel/watcher', '@vscode/sqlite3', '@vscode/vscode-languagedetection', @@ -1468,6 +1541,7 @@ export default tseslint.config( 'vscode-regexpp', 'vscode-textmate', 'worker_threads', + 'ws', '@xterm/addon-clipboard', '@xterm/addon-image', '@xterm/addon-ligatures', @@ -1550,7 +1624,8 @@ export default tseslint.config( 'tas-client', // node module allowed even in /common/ '@microsoft/1ds-core-js', // node module allowed even in /common/ '@microsoft/1ds-post-js', // node module allowed even in /common/ - '@xterm/headless' // node module allowed even in /common/ + '@xterm/headless', // node module allowed even in /common/ + '@vscode/tree-sitter-wasm' // used by agentHost for command auto-approval ] }, { @@ -1917,11 +1992,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 +2013,7 @@ 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/~', @@ -1943,6 +2022,75 @@ export default tseslint.config( '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/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + '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': [ @@ -1953,9 +2101,9 @@ export default tseslint.config( 'vs/editor/contrib/*/~', 'vs/workbench/~', 'vs/workbench/browser/**', - 'vs/workbench/contrib/**', 'vs/workbench/services/*/~', - 'vs/sessions/~' + 'vs/sessions/~', + 'vs/sessions/services/*/~' ] }, { @@ -1974,6 +2122,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/ + ] + }, ] } }, @@ -2050,6 +2224,14 @@ export default tseslint.config( '@modelcontextprotocol/sdk/**/*', '*' // node modules ] + }, + { + 'target': 'test/componentFixtures/playwright/**', + 'restrictions': [ + 'test/componentFixtures/playwright/**', + '@playwright/*', + '*' // node modules + ] } ] } @@ -2147,21 +2329,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 +2399,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/csharp/cgmanifest.json b/extensions/csharp/cgmanifest.json index 6eb3de2f57247..fa99e209e40bc 100644 --- a/extensions/csharp/cgmanifest.json +++ b/extensions/csharp/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "dotnet/csharp-tmLanguage", "repositoryUrl": "https://github.com/dotnet/csharp-tmLanguage", - "commitHash": "2e6860d87d4019b0b793b1e21e9e5c82185a01aa" + "commitHash": "32ee13e806edf480ef9423adcf5ecf61ba39561b" } }, "license": "MIT", diff --git a/extensions/csharp/syntaxes/csharp.tmLanguage.json b/extensions/csharp/syntaxes/csharp.tmLanguage.json index 89b08d5c5b2f7..e344197ccb4e4 100644 --- a/extensions/csharp/syntaxes/csharp.tmLanguage.json +++ b/extensions/csharp/syntaxes/csharp.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/dotnet/csharp-tmLanguage/commit/2e6860d87d4019b0b793b1e21e9e5c82185a01aa", + "version": "https://github.com/dotnet/csharp-tmLanguage/commit/32ee13e806edf480ef9423adcf5ecf61ba39561b", "name": "C#", "scopeName": "source.cs", "patterns": [ @@ -5578,6 +5578,12 @@ { "include": "#preprocessor-app-directive-property" }, + { + "include": "#preprocessor-app-directive-exclude" + }, + { + "include": "#preprocessor-app-directive-include" + }, { "include": "#preprocessor-app-directive-project" }, @@ -5627,6 +5633,28 @@ } } }, + "preprocessor-app-directive-exclude": { + "match": "\\b(exclude)\\b\\s*(.*)?\\s*", + "captures": { + "1": { + "name": "keyword.preprocessor.exclude.cs" + }, + "2": { + "name": "string.unquoted.preprocessor.message.cs" + } + } + }, + "preprocessor-app-directive-include": { + "match": "\\b(include)\\b\\s*(.*)?\\s*", + "captures": { + "1": { + "name": "keyword.preprocessor.include.cs" + }, + "2": { + "name": "string.unquoted.preprocessor.message.cs" + } + } + }, "preprocessor-app-directive-project": { "match": "\\b(project)\\b\\s*(.*)?\\s*", "captures": { diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index 231eda54dba77..2ec3141d90e3c 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -39,21 +39,21 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "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-language-features/server/test/index.js b/extensions/css-language-features/server/test/index.js index 0c9be2d9710c3..f6d750ea75658 100644 --- a/extensions/css-language-features/server/test/index.js +++ b/extensions/css-language-features/server/test/index.js @@ -15,6 +15,10 @@ const options = { timeout: 60000 }; +if (process.env.MOCHA_GREP) { + options.grep = process.env.MOCHA_GREP; +} + if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { 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..fb117421b919a 100644 --- a/extensions/git/package-lock.json +++ b/extensions/git/package-lock.json @@ -11,9 +11,10 @@ "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", - "picomatch": "2.3.1", + "file-type": "21.3.2", + "picomatch": "2.3.2", "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,22 +331,23 @@ "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", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -297,91 +355,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 +408,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..0bb76696eb785 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": [ @@ -3581,7 +3694,7 @@ "git.detectWorktrees": { "type": "boolean", "scope": "resource", - "default": true, + "default": false, "description": "%config.detectWorktrees%" }, "git.detectWorktreesLimit": { @@ -4234,9 +4347,10 @@ "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", - "picomatch": "2.3.1", + "file-type": "21.3.2", + "picomatch": "2.3.2", "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..18d558fdf9bfe 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'; @@ -157,6 +158,10 @@ export class ApiRepository implements Repository { return this.#repository.clean(paths.map(p => Uri.file(p))); } + restore(paths: string[], options?: { staged?: boolean; ref?: string }) { + return this.#repository.restore(paths.map(p => Uri.file(p)), options); + } + diff(cached?: boolean) { return this.#repository.diff(cached); } @@ -211,6 +216,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 +328,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 +359,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..0941959b8cc28 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -259,6 +259,7 @@ export interface Repository { add(paths: string[]): Promise; revert(paths: string[]): Promise; clean(paths: string[]): Promise; + restore(paths: string[], options?: { staged?: boolean; ref?: string }): Promise; apply(patch: string, reverse?: boolean): Promise; apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean; }): Promise; @@ -278,6 +279,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 +317,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 +328,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..83af8866c71da 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' }; @@ -2341,6 +2345,26 @@ export class Repository { } } + async restore(paths: string[], options?: { staged?: boolean; ref?: string }): Promise { + const args = ['restore']; + + if (options?.staged) { + args.push('--staged'); + } + + if (options?.ref) { + args.push('--source', options.ref); + } + + if (paths.length > 0) { + for (const chunk of splitInChunks(paths.map(p => this.sanitizeRelativePath(p)), MAX_CLI_LENGTH)) { + await this.exec([...args, '--', ...chunk]); + } + } else { + await this.exec([...args, '--', '.']); + } + } + async addRemote(name: string, url: string): Promise { const args = ['remote', 'add', name, url]; await this.exec(args); @@ -2420,7 +2444,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 +2470,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..adfbb9a8697b6 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'; @@ -691,6 +691,16 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi this.logger.info(`[Model][openRepository] Opened repository (real path): ${repository.rootRealPath ?? repository.root}`); this.logger.info(`[Model][openRepository] Opened repository (kind): ${gitRepository.kind}`); + // For repositories that are opened in the sessions app, we want to wait for + // the initial `git status` to complete before updating the repository cache + // and firing events. + if (workspace.isAgentSessionsWorkspace) { + await repository.status(); + this._repositoryCache.update(repository.remotes, [], repository.root); + + return; + } + // Do not await this, we want SCM // to know about the repo asap repository.status().then(() => { diff --git a/extensions/git/src/operation.ts b/extensions/git/src/operation.ts index 96fffa4dc8771..961e397bbb87e 100644 --- a/extensions/git/src/operation.ts +++ b/extensions/git/src/operation.ts @@ -52,6 +52,7 @@ export const enum OperationKind { RebaseAbort = 'RebaseAbort', RebaseContinue = 'RebaseContinue', Refresh = 'Refresh', + Restore = 'Restore', RevertFiles = 'RevertFiles', RevList = 'RevList', RevParse = 'RevParse', @@ -72,7 +73,7 @@ export type Operation = AddOperation | ApplyOperation | BlameOperation | BranchO GetBranchOperation | GetBranchesOperation | GetCommitTemplateOperation | GetObjectDetailsOperation | GetObjectFilesOperation | GetRefsOperation | GetRemoteRefsOperation | HashObjectOperation | IgnoreOperation | LogOperation | LogFileOperation | MergeOperation | MergeAbortOperation | MergeBaseOperation | MoveOperation | PostCommitCommandOperation | PullOperation | PushOperation | RemoteOperation | RenameBranchOperation | - RemoveOperation | ResetOperation | RebaseOperation | RebaseAbortOperation | RebaseContinueOperation | RefreshOperation | RevertFilesOperation | + RemoveOperation | ResetOperation | RebaseOperation | RebaseAbortOperation | RebaseContinueOperation | RefreshOperation | RestoreOperation | RevertFilesOperation | RevListOperation | RevParseOperation | SetBranchUpstreamOperation | ShowOperation | StageOperation | StatusOperation | StashOperation | SubmoduleUpdateOperation | SyncOperation | TagOperation | WorktreeOperation; @@ -121,6 +122,7 @@ export type RebaseOperation = BaseOperation & { kind: OperationKind.Rebase }; export type RebaseAbortOperation = BaseOperation & { kind: OperationKind.RebaseAbort }; export type RebaseContinueOperation = BaseOperation & { kind: OperationKind.RebaseContinue }; export type RefreshOperation = BaseOperation & { kind: OperationKind.Refresh }; +export type RestoreOperation = BaseOperation & { kind: OperationKind.Restore }; export type RevertFilesOperation = BaseOperation & { kind: OperationKind.RevertFiles }; export type RevListOperation = BaseOperation & { kind: OperationKind.RevList }; export type RevParseOperation = BaseOperation & { kind: OperationKind.RevParse }; @@ -179,6 +181,7 @@ export const Operation = { RebaseAbort: { kind: OperationKind.RebaseAbort, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as RebaseAbortOperation, RebaseContinue: { kind: OperationKind.RebaseContinue, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as RebaseContinueOperation, Refresh: { kind: OperationKind.Refresh, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as RefreshOperation, + Restore: (showProgress: boolean) => ({ kind: OperationKind.Restore, blocking: false, readOnly: false, remote: false, retry: false, showProgress } as RestoreOperation), RevertFiles: (showProgress: boolean) => ({ kind: OperationKind.RevertFiles, blocking: false, readOnly: false, remote: false, retry: false, showProgress } as RevertFilesOperation), RevList: { kind: OperationKind.RevList, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as RevListOperation, RevParse: { kind: OperationKind.RevParse, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as RevParseOperation, 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..da3c09215cfb1 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); @@ -1349,6 +1365,54 @@ export class Repository implements Disposable { }); } + async restore(resources: Uri[], options?: { staged?: boolean; ref?: string }): Promise { + await this.run( + Operation.Restore(!this.optimisticUpdateEnabled()), + async () => { + const toClean: string[] = []; + const toRestore: string[] = []; + + const resourceStates = [ + ...this.indexGroup.resourceStates, + ...this.workingTreeGroup.resourceStates, + ...this.untrackedGroup.resourceStates + ]; + + for (const resource of resources) { + const scmResource = find(resourceStates, r => r.resourceUri.toString() === resource.toString()); + + if (!scmResource) { + toRestore.push(resource.fsPath); + continue; + } + + switch (scmResource.type) { + case Status.UNTRACKED: + case Status.IGNORED: + toClean.push(resource.fsPath); + break; + + default: + toRestore.push(resource.fsPath); + break; + } + } + + if (toClean.length > 0) { + await this._clean(toClean); + } + + if (toRestore.length > 0) { + await this.repository.restore(toRestore, options); + } + + this.closeDiffEditors([], [...toClean, ...toRestore]); + + // Clear AI contribution tracking for discarded resources + commands.executeCommand('_aiEdits.clearAiContributions', resources); + }); + } + async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise { const indexResources = [...this.indexGroup.resourceStates.map(r => r.resourceUri.fsPath)]; const workingGroupResources = opts.all && opts.all !== 'tracked' ? @@ -1478,9 +1542,6 @@ export class Repository implements Disposable { } async clean(resources: Uri[]): Promise { - const config = workspace.getConfiguration('git'); - const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; - await this.run( Operation.Clean(!this.optimisticUpdateEnabled()), async () => { @@ -1519,33 +1580,7 @@ export class Repository implements Disposable { }); if (toClean.length > 0) { - if (discardUntrackedChangesToTrash) { - try { - // Attempt to move the first resource to the recycle bin/trash to check - // if it is supported. If it fails, we show a confirmation dialog and - // fall back to deletion. - await workspace.fs.delete(Uri.file(toClean[0]), { useTrash: true }); - - const limiter = new Limiter(5); - await Promise.all(toClean.slice(1).map(fsPath => limiter.queue( - async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); - } catch { - const message = isWindows - ? l10n.t('Failed to delete using the Recycle Bin. Do you want to permanently delete instead?') - : l10n.t('Failed to delete using the Trash. Do you want to permanently delete instead?'); - const primaryAction = toClean.length === 1 - ? l10n.t('Delete File') - : l10n.t('Delete All {0} Files', resources.length); - - const result = await window.showWarningMessage(message, { modal: true }, primaryAction); - if (result === primaryAction) { - // Delete permanently - await this.repository.clean(toClean); - } - } - } else { - await this.repository.clean(toClean); - } + await this._clean(toClean); } if (toCheckout.length > 0) { @@ -1582,6 +1617,43 @@ export class Repository implements Disposable { }); } + async _clean(resources: string[]): Promise { + const config = workspace.getConfiguration('git'); + const discardUntrackedChangesToTrash = config.get('discardUntrackedChangesToTrash', true) && !isRemote && !isLinuxSnap; + + if (resources.length === 0) { + return; + } + + if (discardUntrackedChangesToTrash) { + try { + // Attempt to move the first resource to the recycle bin/trash to check + // if it is supported. If it fails, we show a confirmation dialog and + // fall back to deletion. + await workspace.fs.delete(Uri.file(resources[0]), { useTrash: true }); + + const limiter = new Limiter(5); + await Promise.all(resources.slice(1).map(fsPath => limiter.queue( + async () => await workspace.fs.delete(Uri.file(fsPath), { useTrash: true })))); + } catch { + const message = isWindows + ? l10n.t('Failed to delete using the Recycle Bin. Do you want to permanently delete instead?') + : l10n.t('Failed to delete using the Trash. Do you want to permanently delete instead?'); + const primaryAction = resources.length === 1 + ? l10n.t('Delete File') + : l10n.t('Delete All {0} Files', resources.length); + + const result = await window.showWarningMessage(message, { modal: true }, primaryAction); + if (result === primaryAction) { + // Delete permanently + await this.repository.clean(resources); + } + } + } else { + await this.repository.clean(resources); + } + } + closeDiffEditors(indexResources: string[] | undefined, workingTreeResources: string[] | undefined, ignoreSetting = false): void { const config = workspace.getConfiguration('git', Uri.file(this.root)); if (!config.get('closeDiffOnOperation', false) && !ignoreSetting) { return; } @@ -1890,15 +1962,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 +2026,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 +3408,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

', + '\t${2:Page Title}', + '\t', '', '', '\t$0', diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index 1e97c143679e7..4b42603dc6438 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -20,27 +20,6 @@ "vscode": "^1.77.0" } }, - "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==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@microsoft/1ds-core-js": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@microsoft/1ds-core-js/-/1ds-core-js-4.3.4.tgz", @@ -189,16 +168,37 @@ "vscode": "^1.75.0" } }, + "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==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "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": { - "@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" diff --git a/extensions/html-language-features/server/build/javaScriptLibraryLoader.js b/extensions/html-language-features/server/build/javaScriptLibraryLoader.js deleted file mode 100644 index b8b0f8c4eb64a..0000000000000 --- a/extensions/html-language-features/server/build/javaScriptLibraryLoader.js +++ /dev/null @@ -1,132 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// a webpack loader that bundles all library definitions (d.ts) for the embedded JavaScript engine. - -const path = require('path'); -const fs = require('fs'); - -const TYPESCRIPT_LIB_SOURCE = path.join(__dirname, '../../../node_modules/typescript/lib'); -const JQUERY_DTS = path.join(__dirname, '../lib/jquery.d.ts'); - -module.exports = function () { - function getFileName(name) { - return (name === '' ? 'lib.d.ts' : `lib.${name}.d.ts`); - } - function readLibFile(name) { - var srcPath = path.join(TYPESCRIPT_LIB_SOURCE, getFileName(name)); - return fs.readFileSync(srcPath).toString(); - } - - var queue = []; - var in_queue = {}; - - var enqueue = function (name) { - if (in_queue[name]) { - return; - } - in_queue[name] = true; - queue.push(name); - }; - - enqueue('es2020.full'); - - var result = []; - while (queue.length > 0) { - var name = queue.shift(); - var contents = readLibFile(name); - var lines = contents.split(/\r\n|\r|\n/); - - var outputLines = []; - for (let i = 0; i < lines.length; i++) { - let m = lines[i].match(/\/\/\/\s*= 0; i--) { - strResult += `"${result[i].name}": ${result[i].output},\n`; - } - strResult += `\n};` - - strResult += `export function loadLibrary(name: string) : string {\n return libs[name] || ''; \n}`; - - return strResult; -} - -/** - * Escape text such that it can be used in a javascript string enclosed by double quotes (") - */ -function escapeText(text) { - // See http://www.javascriptkit.com/jsref/escapesequence.shtml - var _backspace = '\b'.charCodeAt(0); - var _formFeed = '\f'.charCodeAt(0); - var _newLine = '\n'.charCodeAt(0); - var _nullChar = 0; - var _carriageReturn = '\r'.charCodeAt(0); - var _tab = '\t'.charCodeAt(0); - var _verticalTab = '\v'.charCodeAt(0); - var _backslash = '\\'.charCodeAt(0); - var _doubleQuote = '"'.charCodeAt(0); - - var startPos = 0, chrCode, replaceWith = null, resultPieces = []; - - for (var i = 0, len = text.length; i < len; i++) { - chrCode = text.charCodeAt(i); - switch (chrCode) { - case _backspace: - replaceWith = '\\b'; - break; - case _formFeed: - replaceWith = '\\f'; - break; - case _newLine: - replaceWith = '\\n'; - break; - case _nullChar: - replaceWith = '\\0'; - break; - case _carriageReturn: - replaceWith = '\\r'; - break; - case _tab: - replaceWith = '\\t'; - break; - case _verticalTab: - replaceWith = '\\v'; - break; - case _backslash: - replaceWith = '\\\\'; - break; - case _doubleQuote: - replaceWith = '\\"'; - break; - } - if (replaceWith !== null) { - resultPieces.push(text.substring(startPos, i)); - resultPieces.push(replaceWith); - startPos = i + 1; - replaceWith = null; - } - } - resultPieces.push(text.substring(startPos, len)); - return resultPieces.join(''); -} diff --git a/extensions/html-language-features/server/test/index.js b/extensions/html-language-features/server/test/index.js index 93ffe7d6c09ef..aae624e8ac482 100644 --- a/extensions/html-language-features/server/test/index.js +++ b/extensions/html-language-features/server/test/index.js @@ -15,6 +15,10 @@ const options = { timeout: 60000 }; +if (process.env.MOCHA_GREP) { + options.grep = process.env.MOCHA_GREP; +} + if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY || process.env.GITHUB_WORKSPACE) { options.reporter = 'mocha-multi-reporters'; options.reporterOptions = { diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index eb336e8f89bc5..06dfa8efe48db 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -690,6 +690,23 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP execute: () => Promise; } + const normalizeTrustedDomains = (domains: Record): Record => { + return Object.fromEntries(Object.entries(domains).sort(([a], [b]) => a.localeCompare(b))); + }; + + const updateTrustedDomains = async (updateDomain: string): Promise => { + const config = workspace.getConfiguration(); + const currentDomains = config.get>(SettingIds.trustedDomains, {}); + if (currentDomains[updateDomain] === true) { + return; + } + const nextDomains = normalizeTrustedDomains({ + ...currentDomains, + [updateDomain]: true + }); + await config.update(SettingIds.trustedDomains, nextDomains, true); + }; + const items: QuickPickItemWithAction[] = []; try { @@ -701,10 +718,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP label: l10n.t('Trust Domain: {0}', domain), description: l10n.t('Allow all schemas from this domain'), execute: async () => { - const config = workspace.getConfiguration(); - const currentDomains = config.get>(SettingIds.trustedDomains, {}); - currentDomains[domain] = true; - await config.update(SettingIds.trustedDomains, currentDomains, true); + await updateTrustedDomains(domain); await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); } }); @@ -714,10 +728,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP label: l10n.t('Trust URI: {0}', schemaUri), description: l10n.t('Allow only this specific schema'), execute: async () => { - const config = workspace.getConfiguration(); - const currentDomains = config.get>(SettingIds.trustedDomains, {}); - currentDomains[schemaUri] = true; - await config.update(SettingIds.trustedDomains, currentDomains, true); + await updateTrustedDomains(schemaUri); await commands.executeCommand(CommandIds.workbenchActionOpenSettings, SettingIds.trustedDomains); } }); @@ -926,4 +937,3 @@ export function isSchemaResolveError(d: Diagnostic) { return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } - diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index e5e1b9c133204..d7ff088d13475 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -178,9 +178,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -190,9 +190,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/json-language-features/package.json b/extensions/json-language-features/package.json index fe0a23cb59101..1368b6cda477a 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -59,17 +59,17 @@ "url": { "type": "string", "default": "/user.schema.json", - "description": "%json.schemas.url.desc%" + "markdownDescription": "%json.schemas.url.desc%" }, "fileMatch": { "type": "array", "items": { "type": "string", "default": "MyFile.json", - "description": "%json.schemas.fileMatch.item.desc%" + "markdownDescription": "%json.schemas.fileMatch.item.desc%" }, "minItems": 1, - "description": "%json.schemas.fileMatch.desc%" + "markdownDescription": "%json.schemas.fileMatch.desc%" }, "schema": { "$ref": "http://json-schema.org/draft-07/schema#", @@ -141,7 +141,7 @@ "additionalProperties": { "type": "boolean" }, - "description": "%json.schemaDownload.trustedDomains.desc%", + "markdownDescription": "%json.schemaDownload.trustedDomains.desc%", "tags": [ "usesOnlineServices" ] diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index 9052d3781c9ce..30199b2bb33f3 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json @@ -2,12 +2,12 @@ "displayName": "JSON Language Features", "description": "Provides rich language support for JSON files.", "json.schemas.desc": "Associate schemas to JSON files in the current project.", - "json.schemas.url.desc": "A URL or absolute file path to a schema. Can be a relative path (starting with './') in workspace and workspace folder settings.", - "json.schemas.fileMatch.desc": "An array of file patterns to match against when resolving JSON files to schemas. `*` and '**' can be used as a wildcard. Exclusion patterns can also be defined and start with '!'. A file matches when there is at least one matching pattern and the last matching pattern is not an exclusion pattern.", - "json.schemas.fileMatch.item.desc": "A file pattern that can contain '*' and '**' to match against when resolving JSON files to schemas. When beginning with '!', it defines an exclusion pattern.", + "json.schemas.url.desc": "A URL or absolute file path to a schema. Can be a relative path (starting with `./`) in workspace and workspace folder settings.", + "json.schemas.fileMatch.desc": "An array of file patterns to match against when resolving JSON files to schemas. `*` and `**` can be used as a wildcard. Exclusion patterns can also be defined and start with `!`. A file matches when there is at least one matching pattern and the last matching pattern is not an exclusion pattern.", + "json.schemas.fileMatch.item.desc": "A file pattern that can contain `*` and `**` to match against when resolving JSON files to schemas. When beginning with `!`, it defines an exclusion pattern.", "json.schemas.schema.desc": "The schema definition for the given URL. The schema only needs to be provided to avoid accesses to the schema URL.", "json.format.enable.desc": "Enable/disable default JSON formatter", - "json.format.keepLines.desc" : "Keep all existing new lines when formatting.", + "json.format.keepLines.desc": "Keep all existing new lines when formatting.", "json.validate.enable.desc": "Enable/disable JSON validation.", "json.tracing.desc": "Traces the communication between VS Code and the JSON language server.", "json.colorDecorators.enable.desc": "Enables or disables color decorators", @@ -20,5 +20,5 @@ "json.command.clearCache": "Clear Schema Cache", "json.command.sort": "Sort Document", "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https.", - "json.schemaDownload.trustedDomains.desc": "List of trusted domains for downloading JSON schemas over http(s). Use '*' to trust all domains. '*' can also be used as a wildcard in domain names." + "json.schemaDownload.trustedDomains.desc": "List of trusted domains for downloading JSON schemas over http(s). Use `*` to trust all domains. `*` can also be used as a wildcard in domain names." } diff --git a/extensions/json-language-features/server/.npmignore b/extensions/json-language-features/server/.npmignore index 960a01cc7b5da..f85ce05804aed 100644 --- a/extensions/json-language-features/server/.npmignore +++ b/extensions/json-language-features/server/.npmignore @@ -6,5 +6,4 @@ test/ tsconfig.json .gitignore package-lock.json -extension.webpack.config.js vscode-json-languageserver-*.tgz diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index 811cbcd2e9195..edfb856aaccc7 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -11,7 +11,7 @@ import { import { runSafe, runSafeAsync } from './utils/runner'; import { DiagnosticsSupport, registerDiagnosticsPullSupport, registerDiagnosticsPushSupport } from './utils/validation'; -import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions } from 'vscode-json-languageservice'; +import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions, SeverityLevel } from 'vscode-json-languageservice'; import { getLanguageModelCache } from './languageModelCache'; import { Utils, URI } from 'vscode-uri'; import * as l10n from '@vscode/l10n'; @@ -216,7 +216,13 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) schemas?: JSONSchemaSettings[]; format?: { enable?: boolean }; keepLines?: { enable?: boolean }; - validate?: { enable?: boolean }; + validate?: { + enable?: boolean; + comments?: SeverityLevel; + trailingCommas?: SeverityLevel; + schemaValidation?: SeverityLevel; + schemaRequest?: SeverityLevel; + }; resultLimit?: number; jsonFoldingLimit?: number; jsoncFoldingLimit?: number; @@ -242,6 +248,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) let schemaAssociations: ISchemaAssociations | SchemaConfiguration[] | undefined = undefined; let formatterRegistrations: Thenable[] | null = null; let validateEnabled = true; + let commentsSeverity: SeverityLevel | undefined = undefined; + let trailingCommasSeverity: SeverityLevel | undefined = undefined; + let schemaValidationSeverity: SeverityLevel | undefined = undefined; + let schemaRequestSeverity: SeverityLevel | undefined = undefined; let keepLinesEnabled = false; // The settings have changed. Is sent on server activation as well. @@ -250,6 +260,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) runtime.configureHttpRequests?.(settings?.http?.proxy, !!settings.http?.proxyStrictSSL); jsonConfigurationSettings = settings.json?.schemas; validateEnabled = !!settings.json?.validate?.enable; + commentsSeverity = settings.json?.validate?.comments; + trailingCommasSeverity = settings.json?.validate?.trailingCommas; + schemaValidationSeverity = settings.json?.validate?.schemaValidation; + schemaRequestSeverity = settings.json?.validate?.schemaRequest; keepLinesEnabled = settings.json?.keepLines?.enable || false; updateConfiguration(); @@ -388,7 +402,12 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) return []; // ignore empty documents } const jsonDocument = getJSONDocument(textDocument); - const documentSettings: DocumentLanguageSettings = textDocument.languageId === 'jsonc' ? { comments: 'ignore', trailingCommas: 'warning' } : { comments: 'error', trailingCommas: 'error' }; + const documentSettings: DocumentLanguageSettings = { + comments: commentsSeverity ?? (textDocument.languageId === 'jsonc' ? 'ignore' : 'error'), + trailingCommas: trailingCommasSeverity ?? (textDocument.languageId === 'jsonc' ? 'warning' : 'error'), + schemaValidation: schemaValidationSeverity, + schemaRequest: schemaRequestSeverity + }; return await languageService.doValidation(textDocument, jsonDocument, documentSettings); } diff --git a/extensions/json-language-features/server/src/utils/runner.ts b/extensions/json-language-features/server/src/utils/runner.ts index f7762f6ff31a0..5d4e541c6bd45 100644 --- a/extensions/json-language-features/server/src/utils/runner.ts +++ b/extensions/json-language-features/server/src/utils/runner.ts @@ -65,6 +65,5 @@ export function runSafe(runtime: RuntimeEnvironment, func: () => T, errorV } function cancelValue() { - console.log('cancelled'); return new ResponseError(LSPErrorCodes.RequestCancelled, 'Request cancelled'); } diff --git a/extensions/julia/cgmanifest.json b/extensions/julia/cgmanifest.json index e8c9413cb07ed..936d6124ceec8 100644 --- a/extensions/julia/cgmanifest.json +++ b/extensions/julia/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "JuliaEditorSupport/atom-language-julia", "repositoryUrl": "https://github.com/JuliaEditorSupport/atom-language-julia", - "commitHash": "93454227ce9a7aa92f41b157c6a74f3971b4ae14" + "commitHash": "25cc285b5e8accab4ff7725eeb8594f458b45ce4" } }, "license": "MIT", diff --git a/extensions/julia/syntaxes/julia.tmLanguage.json b/extensions/julia/syntaxes/julia.tmLanguage.json index b222b3ba64441..c7a6b4da10fd0 100644 --- a/extensions/julia/syntaxes/julia.tmLanguage.json +++ b/extensions/julia/syntaxes/julia.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/JuliaEditorSupport/atom-language-julia/commit/93454227ce9a7aa92f41b157c6a74f3971b4ae14", + "version": "https://github.com/JuliaEditorSupport/atom-language-julia/commit/25cc285b5e8accab4ff7725eeb8594f458b45ce4", "name": "Julia", "scopeName": "source.julia", "comment": "This grammar is used by Atom (Oniguruma), GitHub (PCRE), and VSCode (Oniguruma),\nso all regexps must be compatible with both engines.\n\nSpecs:\n- https://github.com/kkos/oniguruma/blob/master/doc/RE\n- https://www.pcre.org/current/doc/html/", @@ -301,7 +301,7 @@ "keyword": { "patterns": [ { - "match": "\\b(?>>} - */ -const mangleMap = new Map(); - -/** - * @param {string} projectPath - */ -function getMangledFileContents(projectPath) { - let entry = mangleMap.get(projectPath); - if (!entry) { - const log = (...data) => fancyLog(ansiColors.blue('[mangler]'), ...data); - log(`Mangling ${projectPath}`); - const ts2tsMangler = new Mangler(projectPath, log, { mangleExports: true, manglePrivateFields: true }); - entry = ts2tsMangler.computeNewFileContents(); - mangleMap.set(projectPath, entry); - } - - return entry; -} - -/** - * @type {webpack.LoaderDefinitionFunction} - */ -module.exports = async function (source, sourceMap, meta) { - if (this.mode !== 'production') { - // Only enable mangling in production builds - return source; - } - if (true) { - // disable mangling for now, SEE https://github.com/microsoft/vscode/issues/204692 - return source; - } - const options = this.getOptions(); - if (options.disabled) { - // Dynamically disabled - return source; - } - - if (source !== fs.readFileSync(this.resourcePath).toString()) { - // File content has changed by previous webpack steps. - // Skip mangling. - return source; - } - - const callback = this.async(); - - const fileContentsMap = await getMangledFileContents(options.configFile); - - const newContents = fileContentsMap.get(this.resourcePath); - callback(null, newContents?.out ?? source, sourceMap, meta); -}; diff --git a/extensions/markdown-basics/package.json b/extensions/markdown-basics/package.json index c77aad6a30149..58cc459ce3f2d 100644 --- a/extensions/markdown-basics/package.json +++ b/extensions/markdown-basics/package.json @@ -8,7 +8,9 @@ "engines": { "vscode": "^1.20.0" }, - "categories": ["Programming Languages"], + "categories": [ + "Programming Languages" + ], "contributes": { "languages": [ { @@ -20,12 +22,16 @@ "extensions": [ ".md", ".mkd", + ".mkdn", ".mdwn", ".mdown", ".markdown", ".markdn", ".mdtxt", ".mdtext", + ".litcoffee", + ".ron", + ".ronn", ".workbook" ], "filenamePatterns": [ diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index b7180c307306d..5bf5923bc38b4 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,12 +10,12 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", "markdown-it-front-matter": "^0.2.4", "morphdom": "^2.7.7", - "picomatch": "^2.3.1", + "picomatch": "^2.3.2", "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", @@ -292,9 +292,9 @@ "license": "ISC" }, "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==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -386,10 +386,13 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -513,9 +516,10 @@ "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" }, "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==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -552,9 +556,10 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", "engines": { "node": ">=8.6" }, diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 745ba66b56c5e..afb4677f2962e 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -230,6 +230,14 @@ "group": "1_markdown" } ], + "modalEditor/editorTitle": [ + { + "command": "markdown.showPreviewToSide", + "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", + "alt": "markdown.showPreview", + "group": "navigation" + } + ], "explorer/context": [ { "command": "markdown.showPreview", @@ -774,12 +782,12 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", "markdown-it-front-matter": "^0.2.4", "morphdom": "^2.7.7", - "picomatch": "^2.3.1", + "picomatch": "^2.3.2", "punycode": "^2.3.1", "vscode-languageclient": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.11", diff --git a/extensions/markdown-language-features/preview-src/activeLineMarker.ts b/extensions/markdown-language-features/preview-src/activeLineMarker.ts index 75c1ed7cbc962..0e63dad4599df 100644 --- a/extensions/markdown-language-features/preview-src/activeLineMarker.ts +++ b/extensions/markdown-language-features/preview-src/activeLineMarker.ts @@ -5,27 +5,27 @@ import { getElementsForSourceLine } from './scroll-sync'; export class ActiveLineMarker { - private _current: any; + #current: HTMLElement | undefined; onDidChangeTextEditorSelection(line: number, documentVersion: number) { const { previous } = getElementsForSourceLine(line, documentVersion); - this._update(previous && (previous.codeElement || previous.element)); + this.#update(previous && (previous.codeElement || previous.element)); } - private _update(before: HTMLElement | undefined) { - this._unmarkActiveElement(this._current); - this._markActiveElement(before); - this._current = before; + #update(before: HTMLElement | undefined) { + this.#unmarkActiveElement(this.#current); + this.#markActiveElement(before); + this.#current = before; } - private _unmarkActiveElement(element: HTMLElement | undefined) { + #unmarkActiveElement(element: HTMLElement | undefined) { if (!element) { return; } element.classList.toggle('code-active-line', false); } - private _markActiveElement(element: HTMLElement | undefined) { + #markActiveElement(element: HTMLElement | undefined) { if (!element) { return; } diff --git a/extensions/markdown-language-features/preview-src/csp.ts b/extensions/markdown-language-features/preview-src/csp.ts index fcc38352da880..4db9d7b116fb4 100644 --- a/extensions/markdown-language-features/preview-src/csp.ts +++ b/extensions/markdown-language-features/preview-src/csp.ts @@ -11,45 +11,49 @@ import { getStrings } from './strings'; * Shows an alert when there is a content security policy violation. */ export class CspAlerter { - private _didShow = false; - private _didHaveCspWarning = false; + #didShow = false; + #didHaveCspWarning = false; - private _messaging?: MessagePoster; + #messaging?: MessagePoster; + + readonly #settingsManager: SettingsManager; constructor( - private readonly _settingsManager: SettingsManager, + settingsManager: SettingsManager, ) { + this.#settingsManager = settingsManager; + document.addEventListener('securitypolicyviolation', () => { - this._onCspWarning(); + this.#onCspWarning(); }); window.addEventListener('message', (event) => { if (event?.data && event.data.name === 'vscode-did-block-svg') { - this._onCspWarning(); + this.#onCspWarning(); } }); } public setPoster(poster: MessagePoster) { - this._messaging = poster; - if (this._didHaveCspWarning) { - this._showCspWarning(); + this.#messaging = poster; + if (this.#didHaveCspWarning) { + this.#showCspWarning(); } } - private _onCspWarning() { - this._didHaveCspWarning = true; - this._showCspWarning(); + #onCspWarning() { + this.#didHaveCspWarning = true; + this.#showCspWarning(); } - private _showCspWarning() { + #showCspWarning() { const strings = getStrings(); - const settings = this._settingsManager.settings; + const settings = this.#settingsManager.settings; - if (this._didShow || settings.disableSecurityWarnings || !this._messaging) { + if (this.#didShow || settings.disableSecurityWarnings || !this.#messaging) { return; } - this._didShow = true; + this.#didShow = true; const notification = document.createElement('a'); notification.innerText = strings.cspAlertMessageText; @@ -59,7 +63,7 @@ export class CspAlerter { notification.setAttribute('role', 'button'); notification.setAttribute('aria-label', strings.cspAlertMessageLabel); notification.onclick = () => { - this._messaging!.postMessage('showPreviewSecuritySelector', { source: settings.source }); + this.#messaging!.postMessage('showPreviewSecuritySelector', { source: settings.source }); }; document.body.appendChild(notification); } diff --git a/extensions/markdown-language-features/preview-src/index.ts b/extensions/markdown-language-features/preview-src/index.ts index 08475ad7fa4be..d481bb24e5351 100644 --- a/extensions/markdown-language-features/preview-src/index.ts +++ b/extensions/markdown-language-features/preview-src/index.ts @@ -14,6 +14,7 @@ import type { ToWebviewMessage } from '../types/previewMessaging'; import { isOfScheme, Schemes } from '../src/util/schemes'; let scrollDisabledCount = 0; +let scrollDisabledTimer: number | undefined; const marker = new ActiveLineMarker(); const settings = new SettingsManager(); @@ -26,12 +27,14 @@ const vscode = acquireVsCodeApi(); interface State { scrollProgress?: number; resource?: string; + line?: number; + fragment?: string; } const originalState: State = vscode.getState() ?? {}; -const state = { +const state: State = { ...originalState, - ...getData('data-state') + ...getData>('data-state') }; if (typeof originalState.scrollProgress !== 'undefined' && originalState?.resource !== state.resource) { @@ -129,7 +132,13 @@ onceDocumentLoaded(() => { const onUpdateView = (() => { const doScroll = throttle((line: number) => { - scrollDisabledCount += 1; + scrollDisabledCount = 1; + if (scrollDisabledTimer) { + clearTimeout(scrollDisabledTimer); + } + scrollDisabledTimer = window.setTimeout(() => { + scrollDisabledCount = 0; + }, 50); doAfterImagesLoaded(() => scrollToRevealSourceLine(line, documentVersion, settings)); }, 50); @@ -335,10 +344,10 @@ document.addEventListener('click', event => { return; } - let node: any = event.target; + let node = event.target as Element | null; while (node) { - if (node.tagName && node.tagName === 'A' && node.href) { - if (node.getAttribute('href').startsWith('#')) { + if (node.tagName && node.tagName === 'A' && (node as HTMLAnchorElement).href) { + if (node.getAttribute('href')?.startsWith('#')) { return; } @@ -346,13 +355,13 @@ document.addEventListener('click', event => { if (!hrefText) { hrefText = node.getAttribute('href'); // Pass through known schemes - if (passThroughLinkSchemes.some(scheme => hrefText.startsWith(scheme))) { + if (hrefText && passThroughLinkSchemes.some(scheme => hrefText!.startsWith(scheme))) { return; } } // If original link doesn't look like a url, delegate back to VS Code to resolve - if (!/^[a-z\-]+:/i.test(hrefText)) { + if (hrefText && !/^[a-z\-]+:/i.test(hrefText)) { messaging.postMessage('openLink', { href: hrefText }); event.preventDefault(); event.stopPropagation(); @@ -361,7 +370,7 @@ document.addEventListener('click', event => { return; } - node = node.parentNode; + node = node.parentElement; } }, true); @@ -369,12 +378,12 @@ window.addEventListener('scroll', throttle(() => { updateScrollProgress(); if (scrollDisabledCount > 0) { - scrollDisabledCount -= 1; - } else { - const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion); - if (typeof line === 'number' && !isNaN(line)) { - messaging.postMessage('revealLine', { line }); - } + return; + } + + const line = getEditorLineNumberForPageOffset(window.scrollY, documentVersion); + if (typeof line === 'number' && !isNaN(line)) { + messaging.postMessage('revealLine', { line }); } }, 50)); diff --git a/extensions/markdown-language-features/preview-src/loading.ts b/extensions/markdown-language-features/preview-src/loading.ts index c6e6d27acd904..f3270b22214e2 100644 --- a/extensions/markdown-language-features/preview-src/loading.ts +++ b/extensions/markdown-language-features/preview-src/loading.ts @@ -5,15 +5,20 @@ import { MessagePoster } from './messaging'; export class StyleLoadingMonitor { - private readonly _unloadedStyles: string[] = []; - private _finishedLoading: boolean = false; + readonly #unloadedStyles: string[] = []; + #finishedLoading: boolean = false; - private _poster?: MessagePoster; + #poster?: MessagePoster; constructor() { - const onStyleLoadError = (event: any) => { - const source = event.target.dataset.source; - this._unloadedStyles.push(source); + const onStyleLoadError = (event: Event | string) => { + if (!(event instanceof Event)) { + return; + } + const source = (event.target as HTMLElement | null)?.dataset.source; + if (source) { + this.#unloadedStyles.push(source); + } }; window.addEventListener('DOMContentLoaded', () => { @@ -25,18 +30,18 @@ export class StyleLoadingMonitor { }); window.addEventListener('load', () => { - if (!this._unloadedStyles.length) { + if (!this.#unloadedStyles.length) { return; } - this._finishedLoading = true; - this._poster?.postMessage('previewStyleLoadError', { unloadedStyles: this._unloadedStyles }); + this.#finishedLoading = true; + this.#poster?.postMessage('previewStyleLoadError', { unloadedStyles: this.#unloadedStyles }); }); } public setPoster(poster: MessagePoster): void { - this._poster = poster; - if (this._finishedLoading) { - poster.postMessage('previewStyleLoadError', { unloadedStyles: this._unloadedStyles }); + this.#poster = poster; + if (this.#finishedLoading) { + poster.postMessage('previewStyleLoadError', { unloadedStyles: this.#unloadedStyles }); } } } diff --git a/extensions/markdown-language-features/preview-src/messaging.ts b/extensions/markdown-language-features/preview-src/messaging.ts index 1fb29f0b55b94..0458cac997c5e 100644 --- a/extensions/markdown-language-features/preview-src/messaging.ts +++ b/extensions/markdown-language-features/preview-src/messaging.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { SettingsManager } from './settings'; +import type { WebviewApi } from 'vscode-webview'; import type { FromWebviewMessage } from '../types/previewMessaging'; +import { SettingsManager } from './settings'; export interface MessagePoster { /** @@ -16,7 +17,7 @@ export interface MessagePoster { ): void; } -export const createPosterForVsCode = (vscode: any, settingsManager: SettingsManager): MessagePoster => { +export const createPosterForVsCode = (vscode: WebviewApi, settingsManager: SettingsManager): MessagePoster => { return { postMessage( type: T['type'], diff --git a/extensions/markdown-language-features/preview-src/scroll-sync.ts b/extensions/markdown-language-features/preview-src/scroll-sync.ts index 33d81094cb59f..f4eae58d47c6f 100644 --- a/extensions/markdown-language-features/preview-src/scroll-sync.ts +++ b/extensions/markdown-language-features/preview-src/scroll-sync.ts @@ -9,18 +9,19 @@ const codeLineClass = 'code-line'; export class CodeLineElement { - private readonly _detailParentElements: readonly HTMLDetailsElement[]; + readonly #detailParentElements: readonly HTMLDetailsElement[]; constructor( readonly element: HTMLElement, readonly line: number, readonly codeElement?: HTMLElement, + readonly endLine?: number, ) { - this._detailParentElements = Array.from(getParentsWithTagName(element, 'DETAILS')); + this.#detailParentElements = Array.from(getParentsWithTagName(element, 'DETAILS')); } get isVisible(): boolean { - if (this._detailParentElements.some(x => !x.open)) { + if (this.#detailParentElements.some(x => !x.open)) { return false; } @@ -55,11 +56,17 @@ const getCodeLineElements = (() => { continue; } - if (element.tagName === 'CODE' && element.parentElement && element.parentElement.tagName === 'PRE') { // Fenced code blocks are a special case since the `code-line` can only be marked on // the `` element and not the parent `
` element.
-					cachedElements.push(new CodeLineElement(element.parentElement, line, element));
+					// Calculate the end line by counting newlines in the code block
+					const text = element.textContent || '';
+					const lineCount = (text.match(/\n/g) || []).length + 1;
+					const endLine = line + lineCount - 1;
+					cachedElements.push(new CodeLineElement(element.parentElement, line, element, endLine));
+				} else if (element.tagName === 'PRE') {
+					// Skip PRE elements as they will be handled via their CODE children
+					// This prevents duplicate entries for the same line number
 				} else if (element.tagName === 'UL' || element.tagName === 'OL') {
 					// Skip adding list elements since the first child has the same code line (and should be preferred)
 				} else {
@@ -112,6 +119,7 @@ export function getLineElementsAtPageOffset(offset: number, documentVersion: num
 	}
 	const hiElement = lines[hi];
 	const hiBounds = getElementBounds(hiElement);
+
 	if (hi >= 1 && hiBounds.top > position) {
 		const loElement = lines[lo];
 		return { previous: loElement, next: hiElement };
@@ -122,9 +130,16 @@ export function getLineElementsAtPageOffset(offset: number, documentVersion: num
 	return { previous: hiElement };
 }
 
-function getElementBounds({ element }: CodeLineElement): { top: number; height: number } {
+function getElementBounds(codeLineElement: CodeLineElement): { top: number; height: number } {
+	const { element, codeElement } = codeLineElement;
 	const myBounds = element.getBoundingClientRect();
 
+	// For fenced code blocks (PRE elements containing CODE), use the full height
+	// Don't look for children as the CODE element itself would be found as a child
+	if (codeElement) {
+		return myBounds;
+	}
+
 	// Some code line elements may contain other code line elements.
 	// In those cases, only take the height up to that child.
 	const codeLineChild = element.querySelector(`.${codeLineClass}`);
@@ -140,6 +155,43 @@ function getElementBounds({ element }: CodeLineElement): { top: number; height:
 	return myBounds;
 }
 
+/**
+ * Get the content bounds for a code line element, accounting for padding.
+ * For code blocks, returns the bounds of the content area (excluding padding).
+ * For other elements, returns the same as getElementBounds.
+ */
+function getContentBounds(codeLineElement: CodeLineElement): {
+	top: number;
+	height: number;
+	paddingTop: number;
+	paddingBottom: number;
+} {
+	const { element, codeElement } = codeLineElement;
+	const bounds = getElementBounds(codeLineElement);
+
+	// For code blocks (PRE elements), account for padding
+	if (codeElement) {
+		const computedStyle = window.getComputedStyle(element);
+		const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
+		const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
+
+		return {
+			top: bounds.top + paddingTop,
+			height: bounds.height - paddingTop - paddingBottom,
+			paddingTop,
+			paddingBottom
+		};
+	}
+
+	// For non-code elements, no padding adjustment needed
+	return {
+		top: bounds.top,
+		height: bounds.height,
+		paddingTop: 0,
+		paddingBottom: 0
+	};
+}
+
 /**
  * Attempt to reveal the element for a source line in the editor.
  */
@@ -160,17 +212,45 @@ export function scrollToRevealSourceLine(line: number, documentVersion: number,
 	let scrollTo = 0;
 	const rect = getElementBounds(previous);
 	const previousTop = rect.top;
-	if (next && next.line !== previous.line) {
-		// Between two elements. Go to percentage offset between them.
+
+
+	// Check if previous is a multi-line code block
+	if (previous.endLine && previous.endLine > previous.line) {
+		if (line < previous.endLine) {
+			// We're inside the code block - scroll proportionally through its content height (excluding padding)
+			const contentBounds = getContentBounds(previous);
+			const progressInCodeBlock = (line - previous.line) / (previous.endLine - previous.line);
+
+
+			// Calculate absolute position to content area
+			const contentAbsoluteTop = window.scrollY + contentBounds.top;
+			const targetAbsoluteY = contentAbsoluteTop + (contentBounds.height * progressInCodeBlock);
+			scrollTo = targetAbsoluteY;
+
+		} else if (next && next.line !== previous.line) {
+			// We're after the code block but before the next element
+			const betweenProgress = (line - previous.endLine) / (next.line - previous.endLine);
+			const elementAbsoluteEnd = window.scrollY + previousTop + rect.height;
+			const nextAbsoluteTop = window.scrollY + next.element.getBoundingClientRect().top;
+			const betweenHeight = nextAbsoluteTop - elementAbsoluteEnd;
+			scrollTo = elementAbsoluteEnd + betweenProgress * betweenHeight;
+		} else {
+			// Shouldn't happen, but fall back to end of element
+			scrollTo = window.scrollY + previousTop + rect.height;
+		}
+	} else if (next && next.line !== previous.line) {
+		// Original logic: Between two elements. Go to percentage offset between them.
 		const betweenProgress = (line - previous.line) / (next.line - previous.line);
-		const previousEnd = previousTop + rect.height;
-		const betweenHeight = next.element.getBoundingClientRect().top - previousEnd;
-		scrollTo = previousEnd + betweenProgress * betweenHeight;
+		const elementAbsoluteEnd = window.scrollY + previousTop + rect.height;
+		const nextAbsoluteTop = window.scrollY + next.element.getBoundingClientRect().top;
+		const betweenHeight = nextAbsoluteTop - elementAbsoluteEnd;
+		scrollTo = elementAbsoluteEnd + betweenProgress * betweenHeight;
 	} else {
 		const progressInElement = line - Math.floor(line);
-		scrollTo = previousTop + (rect.height * progressInElement);
+		scrollTo = window.scrollY + previousTop + (rect.height * progressInElement);
 	}
-	window.scroll(window.scrollX, Math.max(1, window.scrollY + scrollTo));
+
+	window.scroll(window.scrollX, Math.max(1, scrollTo));
 }
 
 export function getEditorLineNumberForPageOffset(offset: number, documentVersion: number): number | null {
@@ -181,12 +261,44 @@ export function getEditorLineNumberForPageOffset(offset: number, documentVersion
 		}
 		const previousBounds = getElementBounds(previous);
 		const offsetFromPrevious = (offset - window.scrollY - previousBounds.top);
+
+
+		// Check if previous is a multi-line code block
+		if (previous.endLine && previous.endLine > previous.line) {
+			// Use content bounds to exclude padding from the calculation
+			const contentBounds = getContentBounds(previous);
+			const offsetFromContent = offset - window.scrollY - contentBounds.top;
+
+
+			// Check if we're within the code block's content area (excluding padding)
+			if (offsetFromContent >= 0 && offsetFromContent <= contentBounds.height) {
+				const progressWithinCodeBlock = offsetFromContent / contentBounds.height;
+				const calculatedLine = previous.line + progressWithinCodeBlock * (previous.endLine - previous.line);
+				return calculatedLine;
+			} else if (next && offsetFromContent > contentBounds.height) {
+				// We're in the gap after the code block content (including bottom padding)
+				const gapOffset = offsetFromContent - contentBounds.height;
+				const nextBounds = getElementBounds(next);
+				const contentEnd = contentBounds.top + contentBounds.height;
+				const gapHeight = nextBounds.top - contentEnd;
+				const progressInGap = gapOffset / gapHeight;
+				const calculatedLine = previous.endLine + progressInGap * (next.line - previous.endLine);
+				return calculatedLine;
+			} else if (offsetFromContent < 0) {
+				// We're in the top padding area
+				// Fall through to original logic
+			}
+		}
+
+		// Original logic
 		if (next) {
 			const progressBetweenElements = offsetFromPrevious / (getElementBounds(next).top - previousBounds.top);
-			return previous.line + progressBetweenElements * (next.line - previous.line);
+			const calculatedLine = previous.line + progressBetweenElements * (next.line - previous.line);
+			return calculatedLine;
 		} else {
 			const progressWithinElement = offsetFromPrevious / (previousBounds.height);
-			return previous.line + progressWithinElement;
+			const calculatedLine = previous.line + progressWithinElement;
+			return calculatedLine;
 		}
 	}
 	return null;
diff --git a/extensions/markdown-language-features/preview-src/settings.ts b/extensions/markdown-language-features/preview-src/settings.ts
index 0fb5d0c2686cc..6d642b58c64e3 100644
--- a/extensions/markdown-language-features/preview-src/settings.ts
+++ b/extensions/markdown-language-features/preview-src/settings.ts
@@ -33,13 +33,13 @@ export function getData(key: string): T {
 }
 
 export class SettingsManager {
-	private _settings: PreviewSettings = getData('data-settings');
+	#settings: PreviewSettings = getData('data-settings');
 
 	public get settings(): PreviewSettings {
-		return this._settings;
+		return this.#settings;
 	}
 
 	public updateSettings(newSettings: PreviewSettings) {
-		this._settings = newSettings;
+		this.#settings = newSettings;
 	}
 }
diff --git a/extensions/markdown-language-features/src/client/client.ts b/extensions/markdown-language-features/src/client/client.ts
index bf7be3f32064c..dc279f02d84ec 100644
--- a/extensions/markdown-language-features/src/client/client.ts
+++ b/extensions/markdown-language-features/src/client/client.ts
@@ -17,37 +17,43 @@ export type LanguageClientConstructor = (name: string, description: string, clie
 
 export class MdLanguageClient implements IDisposable {
 
+	readonly #client: BaseLanguageClient;
+	readonly #workspace: VsCodeMdWorkspace;
+
 	constructor(
-		private readonly _client: BaseLanguageClient,
-		private readonly _workspace: VsCodeMdWorkspace,
-	) { }
+		client: BaseLanguageClient,
+		workspace: VsCodeMdWorkspace,
+	) {
+		this.#client = client;
+		this.#workspace = workspace;
+	}
 
 	dispose(): void {
-		this._client.stop();
-		this._workspace.dispose();
+		this.#client.stop();
+		this.#workspace.dispose();
 	}
 
 	resolveLinkTarget(linkText: string, uri: vscode.Uri): Promise {
-		return this._client.sendRequest(proto.resolveLinkTarget, { linkText, uri: uri.toString() });
+		return this.#client.sendRequest(proto.resolveLinkTarget, { linkText, uri: uri.toString() });
 	}
 
 	getEditForFileRenames(files: ReadonlyArray<{ oldUri: string; newUri: string }>, token: vscode.CancellationToken) {
-		return this._client.sendRequest(proto.getEditForFileRenames, files, token);
+		return this.#client.sendRequest(proto.getEditForFileRenames, files, token);
 	}
 
 	getReferencesToFileInWorkspace(resource: vscode.Uri, token: vscode.CancellationToken) {
-		return this._client.sendRequest(proto.getReferencesToFileInWorkspace, { uri: resource.toString() }, token);
+		return this.#client.sendRequest(proto.getReferencesToFileInWorkspace, { uri: resource.toString() }, token);
 	}
 
 	prepareUpdatePastedLinks(doc: vscode.Uri, ranges: readonly vscode.Range[], token: vscode.CancellationToken) {
-		return this._client.sendRequest(proto.prepareUpdatePastedLinks, {
+		return this.#client.sendRequest(proto.prepareUpdatePastedLinks, {
 			uri: doc.toString(),
 			ranges: ranges.map(range => Range.create(range.start.line, range.start.character, range.end.line, range.end.character)),
 		}, token);
 	}
 
 	getUpdatePastedLinksEdit(pastingIntoDoc: vscode.Uri, edits: readonly vscode.TextEdit[], metadata: string, token: vscode.CancellationToken) {
-		return this._client.sendRequest(proto.getUpdatePastedLinksEdit, {
+		return this.#client.sendRequest(proto.getUpdatePastedLinksEdit, {
 			metadata,
 			pasteIntoDoc: pastingIntoDoc.toString(),
 			edits: edits.map(edit => TextEdit.replace(edit.range, edit.newText)),
diff --git a/extensions/markdown-language-features/src/client/fileWatchingManager.ts b/extensions/markdown-language-features/src/client/fileWatchingManager.ts
index e2010edda8a45..c617a73634df7 100644
--- a/extensions/markdown-language-features/src/client/fileWatchingManager.ts
+++ b/extensions/markdown-language-features/src/client/fileWatchingManager.ts
@@ -17,12 +17,12 @@ type DirWatcherEntry = {
 
 export class FileWatcherManager {
 
-	private readonly _fileWatchers = new Map();
 
-	private readonly _dirWatchers = new ResourceMap<{
+	readonly #dirWatchers = new ResourceMap<{
 		readonly watcher: vscode.FileSystemWatcher;
 		refCount: number;
 	}>();
@@ -35,7 +35,7 @@ export class FileWatcherManager {
 
 		const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(uri, '*'), !listeners.create, !listeners.change, !listeners.delete);
 		const parentDirWatchers: DirWatcherEntry[] = [];
-		this._fileWatchers.set(id, { watcher, dirWatchers: parentDirWatchers });
+		this.#fileWatchers.set(id, { watcher, dirWatchers: parentDirWatchers });
 
 		if (listeners.create) { watcher.onDidCreate(listeners.create); }
 		if (listeners.change) { watcher.onDidChange(listeners.change); }
@@ -46,12 +46,12 @@ export class FileWatcherManager {
 			for (let dirUri = Utils.dirname(uri); dirUri.path.length > 1; dirUri = Utils.dirname(dirUri)) {
 				const disposables: IDisposable[] = [];
 
-				let parentDirWatcher = this._dirWatchers.get(dirUri);
+				let parentDirWatcher = this.#dirWatchers.get(dirUri);
 				if (!parentDirWatcher) {
 					const glob = new vscode.RelativePattern(Utils.dirname(dirUri), Utils.basename(dirUri));
 					const parentWatcher = vscode.workspace.createFileSystemWatcher(glob, !listeners.create, true, !listeners.delete);
 					parentDirWatcher = { refCount: 0, watcher: parentWatcher };
-					this._dirWatchers.set(dirUri, parentDirWatcher);
+					this.#dirWatchers.set(dirUri, parentDirWatcher);
 				}
 				parentDirWatcher.refCount++;
 
@@ -81,16 +81,16 @@ export class FileWatcherManager {
 	}
 
 	delete(id: number): void {
-		const entry = this._fileWatchers.get(id);
+		const entry = this.#fileWatchers.get(id);
 		if (entry) {
 			for (const dirWatcher of entry.dirWatchers) {
 				disposeAll(dirWatcher.disposables);
 
-				const dirWatcherEntry = this._dirWatchers.get(dirWatcher.uri);
+				const dirWatcherEntry = this.#dirWatchers.get(dirWatcher.uri);
 				if (dirWatcherEntry) {
 					if (--dirWatcherEntry.refCount <= 0) {
 						dirWatcherEntry.watcher.dispose();
-						this._dirWatchers.delete(dirWatcher.uri);
+						this.#dirWatchers.delete(dirWatcher.uri);
 					}
 				}
 			}
@@ -98,6 +98,6 @@ export class FileWatcherManager {
 			entry.watcher.dispose();
 		}
 
-		this._fileWatchers.delete(id);
+		this.#fileWatchers.delete(id);
 	}
 }
diff --git a/extensions/markdown-language-features/src/client/inMemoryDocument.ts b/extensions/markdown-language-features/src/client/inMemoryDocument.ts
index 953f0da7c898a..2726adb6de16f 100644
--- a/extensions/markdown-language-features/src/client/inMemoryDocument.ts
+++ b/extensions/markdown-language-features/src/client/inMemoryDocument.ts
@@ -9,7 +9,7 @@ import { ITextDocument } from '../types/textDocument';
 
 export class InMemoryDocument implements ITextDocument {
 
-	private readonly _doc: TextDocument;
+	readonly #doc: TextDocument;
 
 	public readonly uri: vscode.Uri;
 	public readonly version: number;
@@ -21,15 +21,15 @@ export class InMemoryDocument implements ITextDocument {
 	) {
 		this.uri = uri;
 		this.version = version;
-		this._doc = TextDocument.create(this.uri.toString(), 'markdown', 0, contents);
+		this.#doc = TextDocument.create(this.uri.toString(), 'markdown', 0, contents);
 	}
 
 	getText(range?: vscode.Range): string {
-		return this._doc.getText(range);
+		return this.#doc.getText(range);
 	}
 
 	positionAt(offset: number): vscode.Position {
-		const pos = this._doc.positionAt(offset);
+		const pos = this.#doc.positionAt(offset);
 		return new vscode.Position(pos.line, pos.character);
 	}
 }
diff --git a/extensions/markdown-language-features/src/client/workspace.ts b/extensions/markdown-language-features/src/client/workspace.ts
index 9ea3173c9cc1e..b07a93ade7856 100644
--- a/extensions/markdown-language-features/src/client/workspace.ts
+++ b/extensions/markdown-language-features/src/client/workspace.ts
@@ -17,47 +17,47 @@ import { ResourceMap } from '../util/resourceMap';
  */
 export class VsCodeMdWorkspace extends Disposable {
 
-	private readonly _watcher: vscode.FileSystemWatcher | undefined;
+	readonly #watcher: vscode.FileSystemWatcher | undefined;
 
-	private readonly _documentCache = new ResourceMap();
+	readonly #documentCache = new ResourceMap();
 
-	private readonly _utf8Decoder = new TextDecoder('utf-8');
+	readonly #utf8Decoder = new TextDecoder('utf-8');
 
 	constructor() {
 		super();
 
-		this._watcher = this._register(vscode.workspace.createFileSystemWatcher('**/*.md'));
+		this.#watcher = this._register(vscode.workspace.createFileSystemWatcher('**/*.md'));
 
-		this._register(this._watcher.onDidChange(async resource => {
-			this._documentCache.delete(resource);
+		this._register(this.#watcher.onDidChange(async resource => {
+			this.#documentCache.delete(resource);
 		}));
 
-		this._register(this._watcher.onDidDelete(resource => {
-			this._documentCache.delete(resource);
+		this._register(this.#watcher.onDidDelete(resource => {
+			this.#documentCache.delete(resource);
 		}));
 
 		this._register(vscode.workspace.onDidOpenTextDocument(e => {
-			this._documentCache.delete(e.uri);
+			this.#documentCache.delete(e.uri);
 		}));
 
 		this._register(vscode.workspace.onDidCloseTextDocument(e => {
-			this._documentCache.delete(e.uri);
+			this.#documentCache.delete(e.uri);
 		}));
 	}
 
-	private _isRelevantMarkdownDocument(doc: vscode.TextDocument) {
+	#isRelevantMarkdownDocument(doc: vscode.TextDocument) {
 		return isMarkdownFile(doc) && doc.uri.scheme !== 'vscode-bulkeditpreview';
 	}
 
 	public async getOrLoadMarkdownDocument(resource: vscode.Uri): Promise {
-		const existing = this._documentCache.get(resource);
+		const existing = this.#documentCache.get(resource);
 		if (existing) {
 			return existing;
 		}
 
-		const matchingDocument = vscode.workspace.textDocuments.find((doc) => this._isRelevantMarkdownDocument(doc) && doc.uri.toString() === resource.toString());
+		const matchingDocument = vscode.workspace.textDocuments.find((doc) => this.#isRelevantMarkdownDocument(doc) && doc.uri.toString() === resource.toString());
 		if (matchingDocument) {
-			this._documentCache.set(resource, matchingDocument);
+			this.#documentCache.set(resource, matchingDocument);
 			return matchingDocument;
 		}
 
@@ -69,9 +69,9 @@ export class VsCodeMdWorkspace extends Disposable {
 			const bytes = await vscode.workspace.fs.readFile(resource);
 
 			// We assume that markdown is in UTF-8
-			const text = this._utf8Decoder.decode(bytes);
+			const text = this.#utf8Decoder.decode(bytes);
 			const doc = new InMemoryDocument(resource, text, 0);
-			this._documentCache.set(resource, doc);
+			this.#documentCache.set(resource, doc);
 			return doc;
 		} catch {
 			return undefined;
diff --git a/extensions/markdown-language-features/src/commandManager.ts b/extensions/markdown-language-features/src/commandManager.ts
index ae2c4985066d0..0563db020c7df 100644
--- a/extensions/markdown-language-features/src/commandManager.ts
+++ b/extensions/markdown-language-features/src/commandManager.ts
@@ -12,27 +12,27 @@ export interface Command {
 }
 
 export class CommandManager {
-	private readonly _commands = new Map();
+	readonly #commands = new Map();
 
 	public dispose() {
-		for (const registration of this._commands.values()) {
+		for (const registration of this.#commands.values()) {
 			registration.dispose();
 		}
-		this._commands.clear();
+		this.#commands.clear();
 	}
 
 	public register(command: T): vscode.Disposable {
-		this._registerCommand(command.id, command.execute, command);
+		this.#registerCommand(command.id, command.execute, command);
 		return new vscode.Disposable(() => {
-			this._commands.delete(command.id);
+			this.#commands.delete(command.id);
 		});
 	}
 
-	private _registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) {
-		if (this._commands.has(id)) {
+	#registerCommand(id: string, impl: (...args: any[]) => void, thisArg?: any) {
+		if (this.#commands.has(id)) {
 			return;
 		}
 
-		this._commands.set(id, vscode.commands.registerCommand(id, impl, thisArg));
+		this.#commands.set(id, vscode.commands.registerCommand(id, impl, thisArg));
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/copyImage.ts b/extensions/markdown-language-features/src/commands/copyImage.ts
index 86fd349c730fa..09a683ecfb234 100644
--- a/extensions/markdown-language-features/src/commands/copyImage.ts
+++ b/extensions/markdown-language-features/src/commands/copyImage.ts
@@ -10,12 +10,16 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class CopyImageCommand implements Command {
 	public readonly id = '_markdown.copyImage';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-	) { }
+		webviewManager: MarkdownPreviewManager,
+	) {
+		this.#webviewManager = webviewManager;
+	}
 
 	public execute(args: { id: string; resource: string }) {
 		const source = vscode.Uri.parse(args.resource);
-		this._webviewManager.findPreview(source)?.copyImage(args.id);
+		this.#webviewManager.findPreview(source)?.copyImage(args.id);
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/openImage.ts b/extensions/markdown-language-features/src/commands/openImage.ts
index 64b1831df0d98..4a121fd6989ca 100644
--- a/extensions/markdown-language-features/src/commands/openImage.ts
+++ b/extensions/markdown-language-features/src/commands/openImage.ts
@@ -10,12 +10,16 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class OpenImageCommand implements Command {
 	public readonly id = '_markdown.openImage';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-	) { }
+		webviewManager: MarkdownPreviewManager,
+	) {
+		this.#webviewManager = webviewManager;
+	}
 
 	public execute(args: { resource: string; imageSource: string }) {
 		const source = vscode.Uri.parse(args.resource);
-		this._webviewManager.openDocumentLink(args.imageSource, source);
+		this.#webviewManager.openDocumentLink(args.imageSource, source);
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/refreshPreview.ts b/extensions/markdown-language-features/src/commands/refreshPreview.ts
index a94fa7974625e..52f320098b746 100644
--- a/extensions/markdown-language-features/src/commands/refreshPreview.ts
+++ b/extensions/markdown-language-features/src/commands/refreshPreview.ts
@@ -10,13 +10,19 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class RefreshPreviewCommand implements Command {
 	public readonly id = 'markdown.preview.refresh';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+	readonly #engine: MarkdownItEngine;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-		private readonly _engine: MarkdownItEngine
-	) { }
+		webviewManager: MarkdownPreviewManager,
+		engine: MarkdownItEngine
+	) {
+		this.#webviewManager = webviewManager;
+		this.#engine = engine;
+	}
 
 	public execute() {
-		this._engine.cleanCache();
-		this._webviewManager.refresh();
+		this.#engine.cleanCache();
+		this.#webviewManager.refresh();
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/reloadPlugins.ts b/extensions/markdown-language-features/src/commands/reloadPlugins.ts
index 16be408bbef8d..5d410780a2bb5 100644
--- a/extensions/markdown-language-features/src/commands/reloadPlugins.ts
+++ b/extensions/markdown-language-features/src/commands/reloadPlugins.ts
@@ -10,14 +10,20 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class ReloadPlugins implements Command {
 	public readonly id = 'markdown.api.reloadPlugins';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+	readonly #engine: MarkdownItEngine;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-		private readonly _engine: MarkdownItEngine,
-	) { }
+		webviewManager: MarkdownPreviewManager,
+		engine: MarkdownItEngine,
+	) {
+		this.#webviewManager = webviewManager;
+		this.#engine = engine;
+	}
 
 	public execute(): void {
-		this._engine.reloadPlugins();
-		this._engine.cleanCache();
-		this._webviewManager.refresh();
+		this.#engine.reloadPlugins();
+		this.#engine.cleanCache();
+		this.#webviewManager.refresh();
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/renderDocument.ts b/extensions/markdown-language-features/src/commands/renderDocument.ts
index ccefddedbd2ec..ffcbab6d41a62 100644
--- a/extensions/markdown-language-features/src/commands/renderDocument.ts
+++ b/extensions/markdown-language-features/src/commands/renderDocument.ts
@@ -10,11 +10,15 @@ import { ITextDocument } from '../types/textDocument';
 export class RenderDocument implements Command {
 	public readonly id = 'markdown.api.render';
 
+	readonly #engine: MarkdownItEngine;
+
 	public constructor(
-		private readonly _engine: MarkdownItEngine
-	) { }
+		engine: MarkdownItEngine
+	) {
+		this.#engine = engine;
+	}
 
 	public async execute(document: ITextDocument | string): Promise {
-		return (await (this._engine.render(document))).html;
+		return (await (this.#engine.render(document))).html;
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/showPreview.ts b/extensions/markdown-language-features/src/commands/showPreview.ts
index d5d430ade0ee6..4d59062c1a117 100644
--- a/extensions/markdown-language-features/src/commands/showPreview.ts
+++ b/extensions/markdown-language-features/src/commands/showPreview.ts
@@ -53,14 +53,20 @@ async function showPreview(
 export class ShowPreviewCommand implements Command {
 	public readonly id = 'markdown.showPreview';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+	readonly #telemetryReporter: TelemetryReporter;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-		private readonly _telemetryReporter: TelemetryReporter
-	) { }
+		webviewManager: MarkdownPreviewManager,
+		telemetryReporter: TelemetryReporter
+	) {
+		this.#webviewManager = webviewManager;
+		this.#telemetryReporter = telemetryReporter;
+	}
 
 	public execute(mainUri?: vscode.Uri, allUris?: vscode.Uri[], previewSettings?: DynamicPreviewSettings) {
 		for (const uri of Array.isArray(allUris) ? allUris : [mainUri]) {
-			showPreview(this._webviewManager, this._telemetryReporter, uri, {
+			showPreview(this.#webviewManager, this.#telemetryReporter, uri, {
 				sideBySide: false,
 				locked: previewSettings?.locked
 			});
@@ -71,13 +77,19 @@ export class ShowPreviewCommand implements Command {
 export class ShowPreviewToSideCommand implements Command {
 	public readonly id = 'markdown.showPreviewToSide';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+	readonly #telemetryReporter: TelemetryReporter;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-		private readonly _telemetryReporter: TelemetryReporter
-	) { }
+		webviewManager: MarkdownPreviewManager,
+		telemetryReporter: TelemetryReporter
+	) {
+		this.#webviewManager = webviewManager;
+		this.#telemetryReporter = telemetryReporter;
+	}
 
 	public execute(uri?: vscode.Uri, previewSettings?: DynamicPreviewSettings) {
-		showPreview(this._webviewManager, this._telemetryReporter, uri, {
+		showPreview(this.#webviewManager, this.#telemetryReporter, uri, {
 			sideBySide: true,
 			locked: previewSettings?.locked
 		});
@@ -88,13 +100,19 @@ export class ShowPreviewToSideCommand implements Command {
 export class ShowLockedPreviewToSideCommand implements Command {
 	public readonly id = 'markdown.showLockedPreviewToSide';
 
+	readonly #webviewManager: MarkdownPreviewManager;
+	readonly #telemetryReporter: TelemetryReporter;
+
 	public constructor(
-		private readonly _webviewManager: MarkdownPreviewManager,
-		private readonly _telemetryReporter: TelemetryReporter
-	) { }
+		webviewManager: MarkdownPreviewManager,
+		telemetryReporter: TelemetryReporter
+	) {
+		this.#webviewManager = webviewManager;
+		this.#telemetryReporter = telemetryReporter;
+	}
 
 	public execute(uri?: vscode.Uri) {
-		showPreview(this._webviewManager, this._telemetryReporter, uri, {
+		showPreview(this.#webviewManager, this.#telemetryReporter, uri, {
 			sideBySide: true,
 			locked: true
 		});
diff --git a/extensions/markdown-language-features/src/commands/showPreviewSecuritySelector.ts b/extensions/markdown-language-features/src/commands/showPreviewSecuritySelector.ts
index 7ea5a4079ed40..fb6cd9ce9e729 100644
--- a/extensions/markdown-language-features/src/commands/showPreviewSecuritySelector.ts
+++ b/extensions/markdown-language-features/src/commands/showPreviewSecuritySelector.ts
@@ -12,19 +12,25 @@ import { isMarkdownFile } from '../util/file';
 export class ShowPreviewSecuritySelectorCommand implements Command {
 	public readonly id = 'markdown.showPreviewSecuritySelector';
 
+	readonly #previewSecuritySelector: PreviewSecuritySelector;
+	readonly #previewManager: MarkdownPreviewManager;
+
 	public constructor(
-		private readonly _previewSecuritySelector: PreviewSecuritySelector,
-		private readonly _previewManager: MarkdownPreviewManager
-	) { }
+		previewSecuritySelector: PreviewSecuritySelector,
+		previewManager: MarkdownPreviewManager
+	) {
+		this.#previewSecuritySelector = previewSecuritySelector;
+		this.#previewManager = previewManager;
+	}
 
 	public execute(resource: string | undefined) {
-		if (this._previewManager.activePreviewResource) {
-			this._previewSecuritySelector.showSecuritySelectorForResource(this._previewManager.activePreviewResource);
+		if (this.#previewManager.activePreviewResource) {
+			this.#previewSecuritySelector.showSecuritySelectorForResource(this.#previewManager.activePreviewResource);
 		} else if (resource) {
 			const source = vscode.Uri.parse(resource);
-			this._previewSecuritySelector.showSecuritySelectorForResource(source.query ? vscode.Uri.parse(source.query) : source);
+			this.#previewSecuritySelector.showSecuritySelectorForResource(source.query ? vscode.Uri.parse(source.query) : source);
 		} else if (vscode.window.activeTextEditor && isMarkdownFile(vscode.window.activeTextEditor.document)) {
-			this._previewSecuritySelector.showSecuritySelectorForResource(vscode.window.activeTextEditor.document.uri);
+			this.#previewSecuritySelector.showSecuritySelectorForResource(vscode.window.activeTextEditor.document.uri);
 		}
 	}
 }
diff --git a/extensions/markdown-language-features/src/commands/showSource.ts b/extensions/markdown-language-features/src/commands/showSource.ts
index 87d6b21ec68e7..3a6bb3e0e202b 100644
--- a/extensions/markdown-language-features/src/commands/showSource.ts
+++ b/extensions/markdown-language-features/src/commands/showSource.ts
@@ -10,12 +10,16 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class ShowSourceCommand implements Command {
 	public readonly id = 'markdown.showSource';
 
+	readonly #previewManager: MarkdownPreviewManager;
+
 	public constructor(
-		private readonly _previewManager: MarkdownPreviewManager
-	) { }
+		previewManager: MarkdownPreviewManager
+	) {
+		this.#previewManager = previewManager;
+	}
 
 	public execute() {
-		const { activePreviewResource, activePreviewResourceColumn } = this._previewManager;
+		const { activePreviewResource, activePreviewResourceColumn } = this.#previewManager;
 		if (activePreviewResource && activePreviewResourceColumn) {
 			return vscode.workspace.openTextDocument(activePreviewResource).then(document => {
 				return vscode.window.showTextDocument(document, activePreviewResourceColumn);
diff --git a/extensions/markdown-language-features/src/commands/toggleLock.ts b/extensions/markdown-language-features/src/commands/toggleLock.ts
index 9975d4872bbf2..0bc4656d08f7e 100644
--- a/extensions/markdown-language-features/src/commands/toggleLock.ts
+++ b/extensions/markdown-language-features/src/commands/toggleLock.ts
@@ -9,11 +9,15 @@ import { MarkdownPreviewManager } from '../preview/previewManager';
 export class ToggleLockCommand implements Command {
 	public readonly id = 'markdown.preview.toggleLock';
 
+	readonly #previewManager: MarkdownPreviewManager;
+
 	public constructor(
-		private readonly _previewManager: MarkdownPreviewManager
-	) { }
+		previewManager: MarkdownPreviewManager
+	) {
+		this.#previewManager = previewManager;
+	}
 
 	public execute() {
-		this._previewManager.toggleLock();
+		this.#previewManager.toggleLock();
 	}
 }
diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts
index 7791d6b19e42f..e59a207487d8f 100644
--- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/dropOrPasteResource.ts
@@ -36,14 +36,18 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 		...Object.values(rootMediaMimesTypes).map(type => `${type}/*`),
 	];
 
-	private readonly _yieldTo = [
+	readonly #yieldTo = [
 		vscode.DocumentDropOrPasteEditKind.Text,
 		vscode.DocumentDropOrPasteEditKind.Empty.append('markdown', 'link', 'image', 'attachment'), // Prefer notebook attachments
 	];
 
+	readonly #parser: IMdParser;
+
 	constructor(
-		private readonly _parser: IMdParser,
-	) { }
+		parser: IMdParser,
+	) {
+		this.#parser = parser;
+	}
 
 	public async provideDocumentDropEdits(
 		document: vscode.TextDocument,
@@ -51,8 +55,8 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 		dataTransfer: vscode.DataTransfer,
 		token: vscode.CancellationToken,
 	): Promise {
-		const edit = await this._createEdit(document, [new vscode.Range(position, position)], dataTransfer, {
-			insert: this._getEnabled(document, 'editor.drop.enabled'),
+		const edit = await this.#createEdit(document, [new vscode.Range(position, position)], dataTransfer, {
+			insert: this.#getEnabled(document, 'editor.drop.enabled'),
 			copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.drop.copyIntoWorkspace', CopyFilesSettings.MediaFiles)
 		}, undefined, token);
 
@@ -64,7 +68,7 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 		dropEdit.title = edit.label;
 		dropEdit.kind = edit.kind;
 		dropEdit.additionalEdit = edit.additionalEdits;
-		dropEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
+		dropEdit.yieldTo = [...this.#yieldTo, ...edit.yieldTo];
 		return dropEdit;
 	}
 
@@ -75,8 +79,8 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 		context: vscode.DocumentPasteEditContext,
 		token: vscode.CancellationToken,
 	): Promise {
-		const edit = await this._createEdit(document, ranges, dataTransfer, {
-			insert: this._getEnabled(document, 'editor.paste.enabled'),
+		const edit = await this.#createEdit(document, ranges, dataTransfer, {
+			insert: this.#getEnabled(document, 'editor.paste.enabled'),
 			copyIntoWorkspace: vscode.workspace.getConfiguration('markdown', document).get('editor.paste.copyIntoWorkspace', CopyFilesSettings.MediaFiles)
 		}, context, token);
 
@@ -86,11 +90,11 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 
 		const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, edit.label, edit.kind);
 		pasteEdit.additionalEdit = edit.additionalEdits;
-		pasteEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
+		pasteEdit.yieldTo = [...this.#yieldTo, ...edit.yieldTo];
 		return [pasteEdit];
 	}
 
-	private _getEnabled(document: vscode.TextDocument, settingName: string): InsertMarkdownLink {
+	#getEnabled(document: vscode.TextDocument, settingName: string): InsertMarkdownLink {
 		const setting = vscode.workspace.getConfiguration('markdown', document).get(settingName, true);
 		// Convert old boolean values to new enum setting
 		if (setting === false) {
@@ -102,7 +106,7 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 		}
 	}
 
-	private async _createEdit(
+	async #createEdit(
 		document: vscode.TextDocument,
 		ranges: readonly vscode.Range[],
 		dataTransfer: vscode.DataTransfer,
@@ -117,27 +121,27 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 			return;
 		}
 
-		let edit = await this._createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token);
+		let edit = await this.#createEditForMediaFiles(document, dataTransfer, settings.copyIntoWorkspace, token);
 		if (token.isCancellationRequested) {
 			return;
 		}
 
 		if (!edit) {
-			edit = await this._createEditFromUriListData(document, ranges, dataTransfer, context, token);
+			edit = await this.#createEditFromUriListData(document, ranges, dataTransfer, context, token);
 		}
 
 		if (!edit || token.isCancellationRequested) {
 			return;
 		}
 
-		if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, settings.insert, ranges, token))) {
+		if (!(await shouldInsertMarkdownLinkByDefault(this.#parser, document, settings.insert, ranges, token))) {
 			edit.yieldTo.push(vscode.DocumentDropOrPasteEditKind.Empty.append('uri'));
 		}
 
 		return edit;
 	}
 
-	private async _createEditFromUriListData(
+	async #createEditFromUriListData(
 		document: vscode.TextDocument,
 		ranges: readonly vscode.Range[],
 		dataTransfer: vscode.DataTransfer,
@@ -194,7 +198,7 @@ class ResourcePasteOrDropProvider implements vscode.DocumentPasteEditProvider, v
 	 *
 	 * This tries copying files outside of the workspace into the workspace.
 	 */
-	private async _createEditForMediaFiles(
+	async #createEditForMediaFiles(
 		document: vscode.TextDocument,
 		dataTransfer: vscode.DataTransfer,
 		copyIntoWorkspace: CopyFilesSettings,
diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts
index 1625977a72cb8..fe9030704c117 100644
--- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts
@@ -12,7 +12,7 @@ import { CopyFileConfiguration, getCopyFileConfiguration, parseGlob, resolveCopy
 
 export class NewFilePathGenerator {
 
-	private readonly _usedPaths = new Set();
+	readonly #usedPaths = new Set();
 
 	async getNewFilePath(
 		document: vscode.TextDocument,
@@ -33,13 +33,13 @@ export class NewFilePathGenerator {
 
 			const name = i === 0 ? baseName : `${baseName}-${i}`;
 			const uri = vscode.Uri.joinPath(root, name + ext);
-			if (this._wasPathAlreadyUsed(uri)) {
+			if (this.#wasPathAlreadyUsed(uri)) {
 				continue;
 			}
 
 			// Try overwriting if it already exists
 			if (config.overwriteBehavior === 'overwrite') {
-				this._usedPaths.add(uri.toString());
+				this.#usedPaths.add(uri.toString());
 				return { uri, overwrite: true };
 			}
 
@@ -47,17 +47,17 @@ export class NewFilePathGenerator {
 			try {
 				await vscode.workspace.fs.stat(uri);
 			} catch {
-				if (!this._wasPathAlreadyUsed(uri)) {
+				if (!this.#wasPathAlreadyUsed(uri)) {
 					// Does not exist
-					this._usedPaths.add(uri.toString());
+					this.#usedPaths.add(uri.toString());
 					return { uri, overwrite: false };
 				}
 			}
 		}
 	}
 
-	private _wasPathAlreadyUsed(uri: vscode.Uri) {
-		return this._usedPaths.has(uri.toString());
+	#wasPathAlreadyUsed(uri: vscode.Uri) {
+		return this.#usedPaths.has(uri.toString());
 	}
 }
 
diff --git a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts
index a947216fe3272..faad54e10c342 100644
--- a/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/copyFiles/pasteUrlProvider.ts
@@ -21,9 +21,13 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider {
 
 	public static readonly pasteMimeTypes = [Mime.textPlain];
 
+	readonly #parser: IMdParser;
+
 	constructor(
-		private readonly _parser: IMdParser,
-	) { }
+		parser: IMdParser,
+	) {
+		this.#parser = parser;
+	}
 
 	async provideDocumentPasteEdits(
 		document: vscode.TextDocument,
@@ -64,7 +68,7 @@ class PasteUrlEditProvider implements vscode.DocumentPasteEditProvider {
 		workspaceEdit.set(document.uri, edit.edits);
 		pasteEdit.additionalEdit = workspaceEdit;
 
-		if (!(await shouldInsertMarkdownLinkByDefault(this._parser, document, pasteUrlSetting, ranges, token))) {
+		if (!(await shouldInsertMarkdownLinkByDefault(this.#parser, document, pasteUrlSetting, ranges, token))) {
 			pasteEdit.yieldTo = [
 				vscode.DocumentDropOrPasteEditKind.Text,
 				vscode.DocumentDropOrPasteEditKind.Empty.append('uri')
diff --git a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts
index 8df16f4dcc6f7..871333cf4a422 100644
--- a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts
@@ -19,18 +19,18 @@ export enum DiagnosticCode {
 
 class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
 
-	private static readonly _addToIgnoreLinksCommandId = '_markdown.addToIgnoreLinks';
+	static readonly #addToIgnoreLinksCommandId = '_markdown.addToIgnoreLinks';
 
-	private static readonly _metadata: vscode.CodeActionProviderMetadata = {
+	static readonly #metadata: vscode.CodeActionProviderMetadata = {
 		providedCodeActionKinds: [
 			vscode.CodeActionKind.QuickFix
 		],
 	};
 
 	public static register(selector: vscode.DocumentSelector, commandManager: CommandManager): vscode.Disposable {
-		const reg = vscode.languages.registerCodeActionsProvider(selector, new AddToIgnoreLinksQuickFixProvider(), AddToIgnoreLinksQuickFixProvider._metadata);
+		const reg = vscode.languages.registerCodeActionsProvider(selector, new AddToIgnoreLinksQuickFixProvider(), AddToIgnoreLinksQuickFixProvider.#metadata);
 		const commandReg = commandManager.register({
-			id: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
+			id: AddToIgnoreLinksQuickFixProvider.#addToIgnoreLinksCommandId,
 			execute(resource: vscode.Uri, path: string) {
 				const settingId = 'validate.ignoredLinks';
 				const config = vscode.workspace.getConfiguration('markdown', resource);
@@ -58,7 +58,7 @@ class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
 							vscode.CodeActionKind.QuickFix);
 
 						fix.command = {
-							command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
+							command: AddToIgnoreLinksQuickFixProvider.#addToIgnoreLinksCommandId,
 							title: '',
 							arguments: [document.uri, hrefText],
 						};
diff --git a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts
index bda8b721e8bca..23a2ed9e7703a 100644
--- a/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/fileReferences.ts
@@ -13,9 +13,13 @@ export class FindFileReferencesCommand implements Command {
 
 	public readonly id = 'markdown.findAllFileReferences';
 
+	readonly #client: MdLanguageClient;
+
 	constructor(
-		private readonly _client: MdLanguageClient,
-	) { }
+		client: MdLanguageClient,
+	) {
+		this.#client = client;
+	}
 
 	public async execute(resource?: vscode.Uri) {
 		resource ??= vscode.window.activeTextEditor?.document.uri;
@@ -28,7 +32,7 @@ export class FindFileReferencesCommand implements Command {
 			location: vscode.ProgressLocation.Window,
 			title: vscode.l10n.t("Finding file references")
 		}, async (_progress, token) => {
-			const locations = (await this._client.getReferencesToFileInWorkspace(resource, token)).map(loc => {
+			const locations = (await this.#client.getReferencesToFileInWorkspace(resource, token)).map(loc => {
 				return new vscode.Location(vscode.Uri.parse(loc.uri), convertRange(loc.range));
 			});
 
diff --git a/extensions/markdown-language-features/src/languageFeatures/linkUpdater.ts b/extensions/markdown-language-features/src/languageFeatures/linkUpdater.ts
index 5d42a033842c0..d912caa906009 100644
--- a/extensions/markdown-language-features/src/languageFeatures/linkUpdater.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/linkUpdater.ts
@@ -33,46 +33,48 @@ interface RenameAction {
 
 class UpdateLinksOnFileRenameHandler extends Disposable {
 
-	private readonly _delayer = new Delayer(50);
-	private readonly _pendingRenames = new Set();
+	readonly #delayer = new Delayer(50);
+	readonly #pendingRenames = new Set();
+	readonly #client: MdLanguageClient;
 
 	public constructor(
-		private readonly _client: MdLanguageClient,
+		client: MdLanguageClient,
 	) {
 		super();
+		this.#client = client;
 
 		this._register(vscode.workspace.onDidRenameFiles(async (e) => {
 			await Promise.all(e.files.map(async (rename) => {
-				if (await this._shouldParticipateInLinkUpdate(rename.newUri)) {
-					this._pendingRenames.add(rename);
+				if (await this.#shouldParticipateInLinkUpdate(rename.newUri)) {
+					this.#pendingRenames.add(rename);
 				}
 			}));
 
-			if (this._pendingRenames.size) {
-				this._delayer.trigger(() => {
+			if (this.#pendingRenames.size) {
+				this.#delayer.trigger(() => {
 					vscode.window.withProgress({
 						location: vscode.ProgressLocation.Window,
 						title: vscode.l10n.t("Checking for Markdown links to update")
-					}, () => this._flushRenames());
+					}, () => this.#flushRenames());
 				});
 			}
 		}));
 	}
 
-	private async _flushRenames(): Promise {
-		const renames = Array.from(this._pendingRenames);
-		this._pendingRenames.clear();
+	async #flushRenames(): Promise {
+		const renames = Array.from(this.#pendingRenames);
+		this.#pendingRenames.clear();
 
-		const result = await this._getEditsForFileRename(renames, noopToken);
+		const result = await this.#getEditsForFileRename(renames, noopToken);
 
 		if (result?.edit.size) {
-			if (await this._confirmActionWithUser(result.resourcesBeingRenamed)) {
+			if (await this.#confirmActionWithUser(result.resourcesBeingRenamed)) {
 				await vscode.workspace.applyEdit(result.edit);
 			}
 		}
 	}
 
-	private async _confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise {
+	async #confirmActionWithUser(newResources: readonly vscode.Uri[]): Promise {
 		if (!newResources.length) {
 			return false;
 		}
@@ -81,7 +83,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		const setting = config.get(settingNames.enabled);
 		switch (setting) {
 			case UpdateLinksOnFileMoveSetting.Prompt:
-				return this._promptUser(newResources);
+				return this.#promptUser(newResources);
 			case UpdateLinksOnFileMoveSetting.Always:
 				return true;
 			case UpdateLinksOnFileMoveSetting.Never:
@@ -89,7 +91,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 				return false;
 		}
 	}
-	private async _shouldParticipateInLinkUpdate(newUri: vscode.Uri): Promise {
+	async #shouldParticipateInLinkUpdate(newUri: vscode.Uri): Promise {
 		const config = vscode.workspace.getConfiguration('markdown', newUri);
 		const setting = config.get(settingNames.enabled);
 		if (setting === UpdateLinksOnFileMoveSetting.Never) {
@@ -113,7 +115,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		return false;
 	}
 
-	private async _promptUser(newResources: readonly vscode.Uri[]): Promise {
+	async #promptUser(newResources: readonly vscode.Uri[]): Promise {
 		if (!newResources.length) {
 			return false;
 		}
@@ -138,7 +140,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		const choice = await vscode.window.showInformationMessage(
 			newResources.length === 1
 				? vscode.l10n.t("Update Markdown links for '{0}'?", Utils.basename(newResources[0]))
-				: this._getConfirmMessage(vscode.l10n.t("Update Markdown links for the following {0} files?", newResources.length), newResources), {
+				: this.#getConfirmMessage(vscode.l10n.t("Update Markdown links for the following {0} files?", newResources.length), newResources), {
 			modal: true,
 		}, rejectItem, acceptItem, alwaysItem, neverItem);
 
@@ -154,7 +156,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 				config.update(
 					settingNames.enabled,
 					UpdateLinksOnFileMoveSetting.Always,
-					this._getConfigTargetScope(config, settingNames.enabled));
+					this.#getConfigTargetScope(config, settingNames.enabled));
 				return true;
 			}
 			case neverItem: {
@@ -162,7 +164,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 				config.update(
 					settingNames.enabled,
 					UpdateLinksOnFileMoveSetting.Never,
-					this._getConfigTargetScope(config, settingNames.enabled));
+					this.#getConfigTargetScope(config, settingNames.enabled));
 				return false;
 			}
 			default: {
@@ -171,8 +173,8 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		}
 	}
 
-	private async _getEditsForFileRename(renames: readonly RenameAction[], token: vscode.CancellationToken): Promise<{ edit: vscode.WorkspaceEdit; resourcesBeingRenamed: vscode.Uri[] } | undefined> {
-		const result = await this._client.getEditForFileRenames(renames.map(rename => ({ oldUri: rename.oldUri.toString(), newUri: rename.newUri.toString() })), token);
+	async #getEditsForFileRename(renames: readonly RenameAction[], token: vscode.CancellationToken): Promise<{ edit: vscode.WorkspaceEdit; resourcesBeingRenamed: vscode.Uri[] } | undefined> {
+		const result = await this.#client.getEditForFileRenames(renames.map(rename => ({ oldUri: rename.oldUri.toString(), newUri: rename.newUri.toString() })), token);
 		if (!result?.edit.documentChanges?.length) {
 			return undefined;
 		}
@@ -192,7 +194,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		};
 	}
 
-	private _getConfirmMessage(start: string, resourcesToConfirm: readonly vscode.Uri[]): string {
+	#getConfirmMessage(start: string, resourcesToConfirm: readonly vscode.Uri[]): string {
 		const MAX_CONFIRM_FILES = 10;
 
 		const paths = [start];
@@ -211,7 +213,7 @@ class UpdateLinksOnFileRenameHandler extends Disposable {
 		return paths.join('\n');
 	}
 
-	private _getConfigTargetScope(config: vscode.WorkspaceConfiguration, settingsName: string): vscode.ConfigurationTarget {
+	#getConfigTargetScope(config: vscode.WorkspaceConfiguration, settingsName: string): vscode.ConfigurationTarget {
 		const inspected = config.inspect(settingsName);
 		if (inspected?.workspaceFolderValue) {
 			return vscode.ConfigurationTarget.WorkspaceFolder;
diff --git a/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts
index f8a7128eb05fe..b27a869db0edc 100644
--- a/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts
+++ b/extensions/markdown-language-features/src/languageFeatures/updateLinksOnPaste.ts
@@ -13,16 +13,20 @@ class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider
 
 	public static readonly metadataMime = 'application/vnd.vscode.markdown.updatelinks.metadata';
 
+	readonly #client: MdLanguageClient;
+
 	constructor(
-		private readonly _client: MdLanguageClient,
-	) { }
+		client: MdLanguageClient,
+	) {
+		this.#client = client;
+	}
 
 	async prepareDocumentPaste(document: vscode.TextDocument, ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer, token: vscode.CancellationToken): Promise {
-		if (!this._isEnabled(document)) {
+		if (!this.#isEnabled(document)) {
 			return;
 		}
 
-		const metadata = await this._client.prepareUpdatePastedLinks(document.uri, ranges, token);
+		const metadata = await this.#client.prepareUpdatePastedLinks(document.uri, ranges, token);
 		if (token.isCancellationRequested) {
 			return;
 		}
@@ -37,7 +41,7 @@ class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider
 		context: vscode.DocumentPasteEditContext,
 		token: vscode.CancellationToken,
 	): Promise {
-		if (!this._isEnabled(document)) {
+		if (!this.#isEnabled(document)) {
 			return;
 		}
 
@@ -56,7 +60,7 @@ class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider
 		// - copy empty line
 		// - Copy with multiple cursors and paste into multiple locations
 		// - ...
-		const edits = await this._client.getUpdatePastedLinksEdit(document.uri, ranges.map(x => new vscode.TextEdit(x, text)), metadata, token);
+		const edits = await this.#client.getUpdatePastedLinksEdit(document.uri, ranges.map(x => new vscode.TextEdit(x, text)), metadata, token);
 		if (!edits?.length || token.isCancellationRequested) {
 			return;
 		}
@@ -73,7 +77,7 @@ class UpdatePastedLinksEditProvider implements vscode.DocumentPasteEditProvider
 		return [pasteEdit];
 	}
 
-	private _isEnabled(document: vscode.TextDocument): boolean {
+	#isEnabled(document: vscode.TextDocument): boolean {
 		return vscode.workspace.getConfiguration('markdown', document.uri).get('editor.updateLinksOnPaste.enabled', true);
 	}
 }
diff --git a/extensions/markdown-language-features/src/logging.ts b/extensions/markdown-language-features/src/logging.ts
index b5ea76f360816..30839d8c756c1 100644
--- a/extensions/markdown-language-features/src/logging.ts
+++ b/extensions/markdown-language-features/src/logging.ts
@@ -12,11 +12,11 @@ export interface ILogger {
 }
 
 export class VsCodeOutputLogger extends Disposable implements ILogger {
-	private _outputChannelValue?: vscode.LogOutputChannel;
+	#outputChannelValue?: vscode.LogOutputChannel;
 
-	private get _outputChannel() {
-		this._outputChannelValue ??= this._register(vscode.window.createOutputChannel('Markdown', { log: true }));
-		return this._outputChannelValue;
+	get #outputChannel() {
+		this.#outputChannelValue ??= this._register(vscode.window.createOutputChannel('Markdown', { log: true }));
+		return this.#outputChannelValue;
 	}
 
 	constructor() {
@@ -24,6 +24,6 @@ export class VsCodeOutputLogger extends Disposable implements ILogger {
 	}
 
 	public trace(title: string, message: string, data?: any): void {
-		this._outputChannel.trace(`${title}: ${message}`, ...(data ? [JSON.stringify(data, null, 4)] : []));
+		this.#outputChannel.trace(`${title}: ${message}`, ...(data ? [JSON.stringify(data, null, 4)] : []));
 	}
 }
diff --git a/extensions/markdown-language-features/src/markdownEngine.ts b/extensions/markdown-language-features/src/markdownEngine.ts
index 0f4c7eb671792..4ed3186c3807d 100644
--- a/extensions/markdown-language-features/src/markdownEngine.ts
+++ b/extensions/markdown-language-features/src/markdownEngine.ts
@@ -44,37 +44,37 @@ const pluginSourceMap: MarkdownIt.PluginSimple = (md): void => {
 type MarkdownItConfig = Readonly>>;
 
 class TokenCache {
-	private _cachedDocument?: {
+	#cachedDocument?: {
 		readonly uri: vscode.Uri;
 		readonly version: number;
 		readonly config: MarkdownItConfig;
 	};
-	private _tokens?: MarkdownIt.Token[];
+	#tokens?: MarkdownIt.Token[];
 
 	public tryGetCached(document: ITextDocument, config: MarkdownItConfig): MarkdownIt.Token[] | undefined {
-		if (this._cachedDocument
-			&& this._cachedDocument.uri.toString() === document.uri.toString()
-			&& document.version >= 0 && this._cachedDocument.version === document.version
-			&& this._cachedDocument.config.breaks === config.breaks
-			&& this._cachedDocument.config.linkify === config.linkify
+		if (this.#cachedDocument
+			&& this.#cachedDocument.uri.toString() === document.uri.toString()
+			&& document.version >= 0 && this.#cachedDocument.version === document.version
+			&& this.#cachedDocument.config.breaks === config.breaks
+			&& this.#cachedDocument.config.linkify === config.linkify
 		) {
-			return this._tokens;
+			return this.#tokens;
 		}
 		return undefined;
 	}
 
 	public update(document: ITextDocument, config: MarkdownItConfig, tokens: MarkdownIt.Token[]) {
-		this._cachedDocument = {
+		this.#cachedDocument = {
 			uri: document.uri,
 			version: document.version,
 			config,
 		};
-		this._tokens = tokens;
+		this.#tokens = tokens;
 	}
 
 	public clean(): void {
-		this._cachedDocument = undefined;
-		this._tokens = undefined;
+		this.#cachedDocument = undefined;
+		this.#tokens = undefined;
 	}
 }
 
@@ -98,40 +98,45 @@ export interface IMdParser {
 
 export class MarkdownItEngine implements IMdParser {
 
-	private _md?: Promise;
+	#md?: Promise;
 
-	private readonly _tokenCache = new TokenCache();
+	readonly #tokenCache = new TokenCache();
 
 	public readonly slugifier: ISlugifier;
 
+	readonly #contributionProvider: MarkdownContributionProvider;
+	readonly #logger: ILogger;
+
 	public constructor(
-		private readonly _contributionProvider: MarkdownContributionProvider,
+		contributionProvider: MarkdownContributionProvider,
 		slugifier: ISlugifier,
-		private readonly _logger: ILogger,
+		logger: ILogger,
 	) {
+		this.#contributionProvider = contributionProvider;
 		this.slugifier = slugifier;
+		this.#logger = logger;
 
-		_contributionProvider.onContributionsChanged(() => {
+		contributionProvider.onContributionsChanged(() => {
 			// Markdown plugin contributions may have changed
-			this._md = undefined;
-			this._tokenCache.clean();
+			this.#md = undefined;
+			this.#tokenCache.clean();
 		});
 	}
 
 
 	public async getEngine(resource: vscode.Uri | undefined): Promise {
-		const config = this._getConfig(resource);
-		return this._getEngine(config);
+		const config = this.#getConfig(resource);
+		return this.#getEngine(config);
 	}
 
-	private async _getEngine(config: MarkdownItConfig): Promise {
-		if (!this._md) {
-			this._md = (async () => {
+	async #getEngine(config: MarkdownItConfig): Promise {
+		if (!this.#md) {
+			this.#md = (async () => {
 				const markdownIt = await import('markdown-it');
 				let md: MarkdownIt = markdownIt.default(await getMarkdownOptions(() => md));
 				md.linkify.set({ fuzzyLink: false });
 
-				for (const plugin of this._contributionProvider.contributions.markdownItPlugins.values()) {
+				for (const plugin of this.#contributionProvider.contributions.markdownItPlugins.values()) {
 					try {
 						md = (await plugin)(md);
 					} catch (e) {
@@ -154,43 +159,43 @@ export class MarkdownItEngine implements IMdParser {
 					alt: ['paragraph', 'reference', 'blockquote', 'list']
 				});
 
-				this._addImageRenderer(md);
-				this._addFencedRenderer(md);
-				this._addLinkNormalizer(md);
-				this._addLinkValidator(md);
-				this._addNamedHeaders(md);
-				this._addLinkRenderer(md);
+				this.#addImageRenderer(md);
+				this.#addFencedRenderer(md);
+				this.#addLinkNormalizer(md);
+				this.#addLinkValidator(md);
+				this.#addNamedHeaders(md);
+				this.#addLinkRenderer(md);
 				md.use(pluginSourceMap);
 				return md;
 			})();
 		}
 
-		const md = await this._md!;
+		const md = await this.#md!;
 		md.set(config);
 		return md;
 	}
 
 	public reloadPlugins() {
-		this._md = undefined;
+		this.#md = undefined;
 	}
 
-	private _tokenizeDocument(
+	#tokenizeDocument(
 		document: ITextDocument,
 		config: MarkdownItConfig,
 		engine: MarkdownIt
 	): MarkdownIt.Token[] {
-		const cached = this._tokenCache.tryGetCached(document, config);
+		const cached = this.#tokenCache.tryGetCached(document, config);
 		if (cached) {
 			return cached;
 		}
 
-		this._logger.trace('MarkdownItEngine', `tokenizeDocument - ${document.uri}`);
-		const tokens = this._tokenizeString(document.getText(), engine);
-		this._tokenCache.update(document, config, tokens);
+		this.#logger.trace('MarkdownItEngine', `tokenizeDocument - ${document.uri}`);
+		const tokens = this.#tokenizeString(document.getText(), engine);
+		this.#tokenCache.update(document, config, tokens);
 		return tokens;
 	}
 
-	private _tokenizeString(text: string, engine: MarkdownIt) {
+	#tokenizeString(text: string, engine: MarkdownIt) {
 		const env: RenderEnv = {
 			currentDocument: undefined,
 			containingImages: new Set(),
@@ -201,12 +206,12 @@ export class MarkdownItEngine implements IMdParser {
 	}
 
 	public async render(input: ITextDocument | string, resourceProvider?: WebviewResourceProvider): Promise {
-		const config = this._getConfig(typeof input === 'string' ? undefined : input.uri);
-		const engine = await this._getEngine(config);
+		const config = this.#getConfig(typeof input === 'string' ? undefined : input.uri);
+		const engine = await this.#getEngine(config);
 
 		const tokens = typeof input === 'string'
-			? this._tokenizeString(input, engine)
-			: this._tokenizeDocument(input, config, engine);
+			? this.#tokenizeString(input, engine)
+			: this.#tokenizeDocument(input, config, engine);
 
 		const env: RenderEnv = {
 			containingImages: new Set(),
@@ -227,16 +232,16 @@ export class MarkdownItEngine implements IMdParser {
 	}
 
 	public async tokenize(document: ITextDocument): Promise {
-		const config = this._getConfig(document.uri);
-		const engine = await this._getEngine(config);
-		return this._tokenizeDocument(document, config, engine);
+		const config = this.#getConfig(document.uri);
+		const engine = await this.#getEngine(config);
+		return this.#tokenizeDocument(document, config, engine);
 	}
 
 	public cleanCache(): void {
-		this._tokenCache.clean();
+		this.#tokenCache.clean();
 	}
 
-	private _getConfig(resource?: vscode.Uri): MarkdownItConfig {
+	#getConfig(resource?: vscode.Uri): MarkdownItConfig {
 		const config = MarkdownPreviewConfiguration.getForResource(resource ?? null);
 		return {
 			breaks: config.previewLineBreaks,
@@ -245,7 +250,7 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _addImageRenderer(md: MarkdownIt): void {
+	#addImageRenderer(md: MarkdownIt): void {
 		const original = md.renderer.rules.image;
 		md.renderer.rules.image = (tokens: MarkdownIt.Token[], idx: number, options, env: RenderEnv, self) => {
 			const token = tokens[idx];
@@ -254,7 +259,7 @@ export class MarkdownItEngine implements IMdParser {
 				env.containingImages?.add(src);
 
 				if (!token.attrGet('data-src')) {
-					token.attrSet('src', this._toResourceUri(src, env.currentDocument, env.resourceProvider));
+					token.attrSet('src', this.#toResourceUri(src, env.currentDocument, env.resourceProvider));
 					token.attrSet('data-src', src);
 				}
 			}
@@ -267,7 +272,7 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _addFencedRenderer(md: MarkdownIt): void {
+	#addFencedRenderer(md: MarkdownIt): void {
 		const original = md.renderer.rules['fenced'];
 		md.renderer.rules['fenced'] = (tokens: MarkdownIt.Token[], idx: number, options, env, self) => {
 			const token = tokens[idx];
@@ -283,7 +288,7 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _addLinkNormalizer(md: MarkdownIt): void {
+	#addLinkNormalizer(md: MarkdownIt): void {
 		const normalizeLink = md.normalizeLink;
 		md.normalizeLink = (link: string) => {
 			try {
@@ -299,7 +304,7 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _addLinkValidator(md: MarkdownIt): void {
+	#addLinkValidator(md: MarkdownIt): void {
 		const validateLink = md.validateLink;
 		md.validateLink = (link: string) => {
 			return validateLink(link)
@@ -309,10 +314,10 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _addNamedHeaders(md: MarkdownIt): void {
+	#addNamedHeaders(md: MarkdownIt): void {
 		const original = md.renderer.rules.heading_open;
 		md.renderer.rules.heading_open = (tokens: MarkdownIt.Token[], idx: number, options, env: unknown, self) => {
-			const title = this._tokenToPlainText(tokens[idx + 1]);
+			const title = this.#tokenToPlainText(tokens[idx + 1]);
 			const slug = (env as RenderEnv).slugifier ? (env as RenderEnv).slugifier.add(title) : this.slugifier.fromHeading(title);
 			tokens[idx].attrSet('id', slug.value);
 
@@ -324,9 +329,9 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _tokenToPlainText(token: MarkdownIt.Token): string {
+	#tokenToPlainText(token: MarkdownIt.Token): string {
 		if (token.children) {
-			return token.children.map(x => this._tokenToPlainText(x)).join('');
+			return token.children.map(x => this.#tokenToPlainText(x)).join('');
 		}
 
 		switch (token.type) {
@@ -339,7 +344,7 @@ export class MarkdownItEngine implements IMdParser {
 		}
 	}
 
-	private _addLinkRenderer(md: MarkdownIt): void {
+	#addLinkRenderer(md: MarkdownIt): void {
 		const original = md.renderer.rules.link_open;
 
 		md.renderer.rules.link_open = (tokens: MarkdownIt.Token[], idx: number, options, env, self) => {
@@ -357,7 +362,7 @@ export class MarkdownItEngine implements IMdParser {
 		};
 	}
 
-	private _toResourceUri(href: string, currentDocument: vscode.Uri | undefined, resourceProvider: WebviewResourceProvider | undefined): string {
+	#toResourceUri(href: string, currentDocument: vscode.Uri | undefined, resourceProvider: WebviewResourceProvider | undefined): string {
 		try {
 			// Support file:// links
 			if (isOfScheme(Schemes.file, href)) {
diff --git a/extensions/markdown-language-features/src/markdownExtensions.ts b/extensions/markdown-language-features/src/markdownExtensions.ts
index 1357b03d1de80..568a0f0b6386d 100644
--- a/extensions/markdown-language-features/src/markdownExtensions.ts
+++ b/extensions/markdown-language-features/src/markdownExtensions.ts
@@ -119,36 +119,38 @@ export interface MarkdownContributionProvider {
 
 class VSCodeExtensionMarkdownContributionProvider extends Disposable implements MarkdownContributionProvider {
 
-	private _contributions?: MarkdownContributions;
+	#contributions?: MarkdownContributions;
+	readonly #extensionContext: vscode.ExtensionContext;
 
 	public constructor(
-		private readonly _extensionContext: vscode.ExtensionContext,
+		extensionContext: vscode.ExtensionContext,
 	) {
 		super();
+		this.#extensionContext = extensionContext;
 
 		this._register(vscode.extensions.onDidChange(() => {
-			const currentContributions = this._getCurrentContributions();
-			const existingContributions = this._contributions || MarkdownContributions.Empty;
+			const currentContributions = this.#getCurrentContributions();
+			const existingContributions = this.#contributions || MarkdownContributions.Empty;
 			if (!MarkdownContributions.equal(existingContributions, currentContributions)) {
-				this._contributions = currentContributions;
-				this._onContributionsChanged.fire(this);
+				this.#contributions = currentContributions;
+				this.#onContributionsChanged.fire(this);
 			}
 		}));
 	}
 
 	public get extensionUri() {
-		return this._extensionContext.extensionUri;
+		return this.#extensionContext.extensionUri;
 	}
 
-	private readonly _onContributionsChanged = this._register(new vscode.EventEmitter());
-	public readonly onContributionsChanged = this._onContributionsChanged.event;
+	readonly #onContributionsChanged = this._register(new vscode.EventEmitter());
+	public readonly onContributionsChanged = this.#onContributionsChanged.event;
 
 	public get contributions(): MarkdownContributions {
-		this._contributions ??= this._getCurrentContributions();
-		return this._contributions;
+		this.#contributions ??= this.#getCurrentContributions();
+		return this.#contributions;
 	}
 
-	private _getCurrentContributions(): MarkdownContributions {
+	#getCurrentContributions(): MarkdownContributions {
 		return vscode.extensions.all
 			.map(MarkdownContributions.fromExtension)
 			.reduce(MarkdownContributions.merge, MarkdownContributions.Empty);
diff --git a/extensions/markdown-language-features/src/preview/documentRenderer.ts b/extensions/markdown-language-features/src/preview/documentRenderer.ts
index 61182a2443684..f96fce9b745a6 100644
--- a/extensions/markdown-language-features/src/preview/documentRenderer.ts
+++ b/extensions/markdown-language-features/src/preview/documentRenderer.ts
@@ -41,16 +41,28 @@ export interface ImageInfo {
 }
 
 export class MdDocumentRenderer {
+
+	readonly #engine: MarkdownItEngine;
+	readonly #context: vscode.ExtensionContext;
+	readonly #cspArbiter: ContentSecurityPolicyArbiter;
+	readonly #contributionProvider: MarkdownContributionProvider;
+	readonly #logger: ILogger;
+
 	constructor(
-		private readonly _engine: MarkdownItEngine,
-		private readonly _context: vscode.ExtensionContext,
-		private readonly _cspArbiter: ContentSecurityPolicyArbiter,
-		private readonly _contributionProvider: MarkdownContributionProvider,
-		private readonly _logger: ILogger
+		engine: MarkdownItEngine,
+		context: vscode.ExtensionContext,
+		cspArbiter: ContentSecurityPolicyArbiter,
+		contributionProvider: MarkdownContributionProvider,
+		logger: ILogger
 	) {
+		this.#engine = engine;
+		this.#context = context;
+		this.#cspArbiter = cspArbiter;
+		this.#contributionProvider = contributionProvider;
+		this.#logger = logger;
 		this.iconPath = {
-			dark: vscode.Uri.joinPath(this._context.extensionUri, 'media', 'preview-dark.svg'),
-			light: vscode.Uri.joinPath(this._context.extensionUri, 'media', 'preview-light.svg'),
+			dark: vscode.Uri.joinPath(this.#context.extensionUri, 'media', 'preview-dark.svg'),
+			light: vscode.Uri.joinPath(this.#context.extensionUri, 'media', 'preview-light.svg'),
 		};
 	}
 
@@ -76,15 +88,15 @@ export class MdDocumentRenderer {
 			scrollPreviewWithEditor: config.scrollPreviewWithEditor,
 			scrollEditorWithPreview: config.scrollEditorWithPreview,
 			doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,
-			disableSecurityWarnings: this._cspArbiter.shouldDisableSecurityWarnings(),
+			disableSecurityWarnings: this.#cspArbiter.shouldDisableSecurityWarnings(),
 			webviewResourceRoot: resourceProvider.asWebviewUri(markdownDocument.uri).toString(),
 		};
 
-		this._logger.trace('DocumentRenderer', `provideTextDocumentContent - ${markdownDocument.uri}`, initialData);
+		this.#logger.trace('DocumentRenderer', `provideTextDocumentContent - ${markdownDocument.uri}`, initialData);
 
 		// Content Security Policy
 		const nonce = generateUuid();
-		const csp = this._getCsp(resourceProvider, sourceUri, nonce);
+		const csp = this.#getCsp(resourceProvider, sourceUri, nonce);
 
 		const body = await this.renderBody(markdownDocument, resourceProvider);
 		if (token.isCancellationRequested) {
@@ -92,7 +104,7 @@ export class MdDocumentRenderer {
 		}
 
 		const html = `
-			
+			
 			
 				
 				
@@ -101,12 +113,12 @@ export class MdDocumentRenderer {
 					data-strings="${escapeAttribute(JSON.stringify(previewStrings))}"
 					data-state="${escapeAttribute(JSON.stringify(state || {}))}"
 					data-initial-md-content="${escapeAttribute(body.html)}">
-				
-				${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..f2dd4a988b5f9 --- /dev/null +++ b/scripts/sync-agent-host-protocol.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * 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): 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`; + 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); + } + + // Write the source commit hash to a single version file + const versionFile = path.join(DEST_DIR, '.ahp-version'); + fs.writeFileSync(versionFile, commitHash + '\n', 'utf-8'); + console.log(` .ahp-version -> ${commitHash}`); + + console.log(); + console.log('Done.'); +} + +main(); diff --git a/scripts/test-integration.bat b/scripts/test-integration.bat index 2be5bfef0a074..5c68dbec12e0e 100644 --- a/scripts/test-integration.bat +++ b/scripts/test-integration.bat @@ -1,11 +1,90 @@ @echo off setlocal -pushd %~dp0\.. +:: Capture script directory before any call :label changes %~dp0 +set "SCRIPT_DIR=%~dp0" + +pushd %SCRIPT_DIR%\.. + +:: Parse arguments for help and filters +set "HAS_FILTER=" +set "RUN_FILE=" +set "RUN_GLOB=" +set "GREP_PATTERN=" +set "SUITE_FILTER=" +set "SHOW_HELP=" + +:parse_args +if "%~1"=="" goto done_parsing +if /i "%~1"=="--help" (set SHOW_HELP=1& shift & goto parse_args) +if /i "%~1"=="-h" (set SHOW_HELP=1& shift & goto parse_args) +if /i "%~1"=="--run" (set "RUN_FILE=%~2"& set HAS_FILTER=1& shift & shift & goto parse_args) +if /i "%~1"=="--grep" (set "GREP_PATTERN=%~2"& shift & shift & goto parse_args) +if /i "%~1"=="-g" (set "GREP_PATTERN=%~2"& shift & shift & goto parse_args) +if /i "%~1"=="-f" (set "GREP_PATTERN=%~2"& shift & shift & goto parse_args) +if /i "%~1"=="--runGlob" (set "RUN_GLOB=%~2"& set HAS_FILTER=1& shift & shift & goto parse_args) +if /i "%~1"=="--glob" (set "RUN_GLOB=%~2"& set HAS_FILTER=1& shift & shift & goto parse_args) +if /i "%~1"=="--runGrep" (set "RUN_GLOB=%~2"& set HAS_FILTER=1& shift & shift & goto parse_args) +if /i "%~1"=="--suite" (set "SUITE_FILTER=%~2"& shift & shift & goto parse_args) +shift +goto parse_args +:done_parsing + +if defined SHOW_HELP ( + echo Usage: %~nx0 [options] + echo. + echo Runs integration tests. When no filters are given, all integration tests + echo ^(node.js integration tests + extension host tests^) are run. + echo. + echo --run and --runGlob select which node.js integration test files to load. + echo Extension host tests are skipped when these options are used. + echo. + echo --grep filters test cases by name across all test runners. When used alone, + echo the pattern is applied to both node.js integration tests and all extension + echo host suites. When combined with --suite, only the selected suites are run. + echo. + echo --suite selects which extension host test suites to run. + echo Node.js integration tests are skipped when this option is used. + echo. + echo Options: + echo --run ^ run tests from a specific file ^(src/ path^) + echo --runGlob, --glob ^ select test files by path glob ^(e.g. '**\*.integrationTest.js'^) + echo --grep, -g, -f ^ filter test cases by name ^(matched against test titles^) + echo --suite ^ run only matching extension host test suites + echo supports comma-separated list + echo --help, -h show this help + echo. + echo Available suites: + echo api-folder, api-workspace, colorize, terminal-suggest, typescript, + echo markdown, emmet, git, git-base, ipynb, notebook-renderers, + echo configuration-editing, github-authentication, css, html + echo. + echo All other options are forwarded to the node.js test runner ^(see scripts\test.bat --help^). + echo Note: extra options are not forwarded to extension host suites ^(--suite mode^). + echo. + echo Examples: + echo %~nx0 + echo %~nx0 --run src\vs\editor\test\browser\controller.integrationTest.ts + echo %~nx0 --grep "some test name" + echo %~nx0 --runGlob "**\*.integrationTest.js" + echo %~nx0 --suite git # run only Git tests + echo %~nx0 --suite "api-folder,api-workspace" # run multiple suites + echo %~nx0 --suite api-folder --grep "some test" # grep within a suite + exit /b 0 +) set VSCODEUSERDATADIR=%TEMP%\vscodeuserfolder-%RANDOM%-%TIME:~6,2% -set VSCODECRASHDIR=%~dp0\..\.build\crashes -set VSCODELOGSDIR=%~dp0\..\.build\logs\integration-tests +set VSCODECRASHDIR=%SCRIPT_DIR%\..\.build\crashes +set VSCODELOGSDIR=%SCRIPT_DIR%\..\.build\logs\integration-tests + +:: Seed user settings to disable OS notifications (dock bounce, toast, etc.) +if not exist "%VSCODEUSERDATADIR%\User" mkdir "%VSCODEUSERDATADIR%\User" +( +echo { +echo "chat.notifyWindowOnConfirmation": "off", +echo "chat.notifyWindowOnResponseReceived": "off" +echo } +) > "%VSCODEUSERDATADIR%\User\settings.json" :: Figure out which Electron to use for running tests if "%INTEGRATION_TEST_ELECTRON_PATH%"=="" ( @@ -25,105 +104,213 @@ echo Storing crash reports into '%VSCODECRASHDIR%'. echo Storing log files into '%VSCODELOGSDIR%'. -:: Unit tests +:: Validate --suite filter matches at least one known suite +if defined SUITE_FILTER ( + set "_any_match=" + for %%s in (api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication css html) do ( + call :should_run_suite %%s && set "_any_match=1" + ) + if not defined _any_match ( + echo Error: no suites match filter '%SUITE_FILTER%' + echo Available suites: api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication css html + exit /b 1 + ) +) + + +:: Node.js integration tests +if defined SUITE_FILTER goto skip_nodejs_tests echo. echo ### node.js integration tests -call .\scripts\test.bat --runGlob **\*.integrationTest.js %* +if defined RUN_GLOB ( + call .\scripts\test.bat %* +) else if defined RUN_FILE ( + call .\scripts\test.bat %* +) else ( + call .\scripts\test.bat --runGlob **\*.integrationTest.js %* +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_nodejs_tests + +:: Skip extension host tests when a non-suite filter is active +if not defined SUITE_FILTER if defined HAS_FILTER ( + echo. + echo Filter active, skipping extension host tests. + rmdir /s /q "%VSCODEUSERDATADIR%" 2>nul + exit /b 0 +) :: Tests in the extension host +:: Forward grep pattern to extension test runners +if defined GREP_PATTERN set "MOCHA_GREP=%GREP_PATTERN%" + set API_TESTS_EXTRA_ARGS=--disable-telemetry --disable-experiments --skip-welcome --skip-release-notes --crash-reporter-directory=%VSCODECRASHDIR% --logsPath=%VSCODELOGSDIR% --no-cached-data --disable-updates --use-inmemory-secretstorage --disable-extensions --disable-workspace-trust --user-data-dir=%VSCODEUSERDATADIR% +call :should_run_suite api-folder || goto skip_api_folder echo. echo ### API tests (folder) -call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\vscode-api-tests\testWorkspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=%~dp0\..\extensions\vscode-api-tests --extensionTestsPath=%~dp0\..\extensions\vscode-api-tests\out\singlefolder-tests %API_TESTS_EXTRA_ARGS% +call "%INTEGRATION_TEST_ELECTRON_PATH%" %SCRIPT_DIR%\..\extensions\vscode-api-tests\testWorkspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=%SCRIPT_DIR%\..\extensions\vscode-api-tests --extensionTestsPath=%SCRIPT_DIR%\..\extensions\vscode-api-tests\out\singlefolder-tests %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +:skip_api_folder +call :should_run_suite api-workspace || goto skip_api_workspace echo. echo ### API tests (workspace) -call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\vscode-api-tests\testworkspace.code-workspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=%~dp0\..\extensions\vscode-api-tests --extensionTestsPath=%~dp0\..\extensions\vscode-api-tests\out\workspace-tests %API_TESTS_EXTRA_ARGS% +call "%INTEGRATION_TEST_ELECTRON_PATH%" %SCRIPT_DIR%\..\extensions\vscode-api-tests\testworkspace.code-workspace --enable-proposed-api=vscode.vscode-api-tests --extensionDevelopmentPath=%SCRIPT_DIR%\..\extensions\vscode-api-tests --extensionTestsPath=%SCRIPT_DIR%\..\extensions\vscode-api-tests\out\workspace-tests %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +:skip_api_workspace +call :should_run_suite colorize || goto skip_colorize echo. echo ### Colorize tests -call npm run test-extension -- -l vscode-colorize-tests +if defined GREP_PATTERN ( + call npm run test-extension -- -l vscode-colorize-tests --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l vscode-colorize-tests +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_colorize +call :should_run_suite terminal-suggest || goto skip_terminal_suggest echo. echo ### Terminal Suggest tests -call npm run test-extension -- -l terminal-suggest --enable-proposed-api=vscode.vscode-api-tests +if defined GREP_PATTERN ( + call npm run test-extension -- -l terminal-suggest --enable-proposed-api=vscode.vscode-api-tests --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l terminal-suggest --enable-proposed-api=vscode.vscode-api-tests +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_terminal_suggest +call :should_run_suite typescript || goto skip_typescript echo. echo ### TypeScript tests -call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\typescript-language-features\test-workspace --extensionDevelopmentPath=%~dp0\..\extensions\typescript-language-features --extensionTestsPath=%~dp0\..\extensions\typescript-language-features\out\test\unit %API_TESTS_EXTRA_ARGS% +call "%INTEGRATION_TEST_ELECTRON_PATH%" %SCRIPT_DIR%\..\extensions\typescript-language-features\test-workspace --extensionDevelopmentPath=%SCRIPT_DIR%\..\extensions\typescript-language-features --extensionTestsPath=%SCRIPT_DIR%\..\extensions\typescript-language-features\out\test\unit %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +:skip_typescript +call :should_run_suite markdown || goto skip_markdown echo. echo ### Markdown tests -call npm run test-extension -- -l markdown-language-features +if defined GREP_PATTERN ( + call npm run test-extension -- -l markdown-language-features --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l markdown-language-features +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_markdown +call :should_run_suite emmet || goto skip_emmet echo. echo ### Emmet tests -call "%INTEGRATION_TEST_ELECTRON_PATH%" %~dp0\..\extensions\emmet\test-workspace --extensionDevelopmentPath=%~dp0\..\extensions\emmet --extensionTestsPath=%~dp0\..\extensions\emmet\out\test %API_TESTS_EXTRA_ARGS% +call "%INTEGRATION_TEST_ELECTRON_PATH%" %SCRIPT_DIR%\..\extensions\emmet\test-workspace --extensionDevelopmentPath=%SCRIPT_DIR%\..\extensions\emmet --extensionTestsPath=%SCRIPT_DIR%\..\extensions\emmet\out\test %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +:skip_emmet +call :should_run_suite git || goto skip_git echo. echo ### Git tests for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set TEMPDIR=%%i set GITWORKSPACE=%TEMPDIR%\git-%RANDOM% mkdir %GITWORKSPACE% -call "%INTEGRATION_TEST_ELECTRON_PATH%" %GITWORKSPACE% --extensionDevelopmentPath=%~dp0\..\extensions\git --extensionTestsPath=%~dp0\..\extensions\git\out\test %API_TESTS_EXTRA_ARGS% +call "%INTEGRATION_TEST_ELECTRON_PATH%" %GITWORKSPACE% --extensionDevelopmentPath=%SCRIPT_DIR%\..\extensions\git --extensionTestsPath=%SCRIPT_DIR%\..\extensions\git\out\test %API_TESTS_EXTRA_ARGS% if %errorlevel% neq 0 exit /b %errorlevel% +:skip_git +call :should_run_suite git-base || goto skip_git_base echo. echo ### Git Base tests -call npm run test-extension -- -l git-base +if defined GREP_PATTERN ( + call npm run test-extension -- -l git-base --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l git-base +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_git_base +call :should_run_suite ipynb || goto skip_ipynb echo. echo ### Ipynb tests -call npm run test-extension -- -l ipynb +if defined GREP_PATTERN ( + call npm run test-extension -- -l ipynb --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l ipynb +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_ipynb +call :should_run_suite notebook-renderers || goto skip_notebook_renderers echo. echo ### Notebook Output tests -call npm run test-extension -- -l notebook-renderers +if defined GREP_PATTERN ( + call npm run test-extension -- -l notebook-renderers --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l notebook-renderers +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_notebook_renderers +call :should_run_suite configuration-editing || goto skip_configuration_editing echo. echo ### Configuration editing tests -set CFWORKSPACE=%TEMPDIR%\cf-%RANDOM% -mkdir %CFWORKSPACE% -call npm run test-extension -- -l configuration-editing +if defined GREP_PATTERN ( + call npm run test-extension -- -l configuration-editing --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l configuration-editing +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_configuration_editing +call :should_run_suite github-authentication || goto skip_github_authentication echo. echo ### GitHub Authentication tests -call npm run test-extension -- -l github-authentication +if defined GREP_PATTERN ( + call npm run test-extension -- -l github-authentication --grep "%GREP_PATTERN%" +) else ( + call npm run test-extension -- -l github-authentication +) if %errorlevel% neq 0 exit /b %errorlevel% +:skip_github_authentication :: Tests standalone (CommonJS) +call :should_run_suite css || goto skip_css echo. echo ### CSS tests -call %~dp0\node-electron.bat %~dp0\..\extensions\css-language-features/server/test/index.js +call %SCRIPT_DIR%\node-electron.bat %SCRIPT_DIR%\..\extensions\css-language-features/server/test/index.js if %errorlevel% neq 0 exit /b %errorlevel% +:skip_css +call :should_run_suite html || goto skip_html echo. echo ### HTML tests -call %~dp0\node-electron.bat %~dp0\..\extensions\html-language-features/server/test/index.js +call %SCRIPT_DIR%\node-electron.bat %SCRIPT_DIR%\..\extensions\html-language-features/server/test/index.js if %errorlevel% neq 0 exit /b %errorlevel% +:skip_html :: Cleanup -rmdir /s /q %VSCODEUSERDATADIR% +rmdir /s /q "%VSCODEUSERDATADIR%" popd +goto :end + +:: Subroutine: check whether a suite should run based on SUITE_FILTER. +:: Returns errorlevel 0 if the suite should run, 1 if it should be skipped. +:should_run_suite +if not defined SUITE_FILTER exit /b 0 +set "_suite_name=%~1" +:: Replace commas with spaces so for-loop tokenizes correctly +set "_filter=%SUITE_FILTER:,= %" +for %%p in (%_filter%) do ( + if /i "%%p"=="%_suite_name%" exit /b 0 +) +exit /b 1 + +:end endlocal diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh index e3c391004f8dc..9c542509142d3 100755 --- a/scripts/test-integration.sh +++ b/scripts/test-integration.sh @@ -8,11 +8,126 @@ else ROOT=$(dirname $(dirname $(readlink -f $0))) fi +cd "$ROOT" + +# Parse arguments +EXTRA_ARGS=() +RUN_FILE="" +RUN_GLOB="" +GREP_PATTERN="" +SUITE_FILTER="" +HELP=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + HELP=true + shift + ;; + --run) + RUN_FILE="$2" + EXTRA_ARGS+=("$1" "$2") + shift 2 + ;; + --grep|-g|-f) + GREP_PATTERN="$2" + EXTRA_ARGS+=("$1" "$2") + shift 2 + ;; + --runGlob|--glob|--runGrep) + RUN_GLOB="$2" + EXTRA_ARGS+=("$1" "$2") + shift 2 + ;; + --suite) + SUITE_FILTER="$2" + shift 2 + ;; + *) + EXTRA_ARGS+=("$1") + shift + ;; + esac +done + +# Known suite names (used for help text and validation) +KNOWN_SUITES="api-folder api-workspace colorize terminal-suggest typescript markdown emmet git git-base ipynb notebook-renderers configuration-editing github-authentication css html" + +if $HELP; then + echo "Usage: $0 [options]" + echo "" + echo "Runs integration tests. When no filters are given, all integration tests" + echo "(node.js integration tests + extension host tests) are run." + echo "" + echo "--run and --runGlob select which node.js integration test files to load." + echo "Extension host tests are skipped when these options are used." + echo "" + echo "--grep filters test cases by name across all test runners. When used alone," + echo "the pattern is applied to both node.js integration tests and all extension" + echo "host suites. When combined with --suite, only the selected suites are run." + echo "" + echo "--suite selects which extension host test suites to run." + echo "Node.js integration tests are skipped when this option is used." + echo "" + echo "Options:" + echo " --run run tests from a specific file (src/ path)" + echo " --runGlob, --glob select test files by path glob (e.g. '**/editor/**/*.integrationTest.js')" + echo " --grep, -g, -f filter test cases by name (matched against test titles)" + echo " --suite run only matching extension host test suites" + echo " supports comma-separated list and glob patterns" + echo " --help, -h show this help" + echo "" + echo "Available suites: $KNOWN_SUITES" + echo "" + echo "All other options are forwarded to the node.js test runner (see scripts/test.sh --help)." + echo "Note: extra options are not forwarded to extension host suites (--suite mode)." + echo "" + echo "Examples:" + echo " $0 # run all integration tests" + echo " $0 --run src/vs/editor/test/browser/controller.integrationTest.ts" + echo " $0 --grep 'some test name'" + echo " $0 --runGlob '**/editor/**/*.integrationTest.js'" + echo " $0 --suite git # run only Git tests" + echo " $0 --suite 'api*' # run API folder + workspace tests" + echo " $0 --suite 'git,emmet,typescript' # run multiple suites" + echo " $0 --suite api-folder --grep 'some test' # grep within a suite" + exit 0 +fi + +HAS_FILTER=false +if [[ -n "$RUN_FILE" || -n "$RUN_GLOB" ]]; then + HAS_FILTER=true +fi + +# Check whether a given suite name matches the --suite filter. +# Supports comma-separated patterns with shell globbing (e.g. "git*,api*"). +should_run_suite() { + if [[ -z "$SUITE_FILTER" ]]; then + return 0 + fi + IFS=',' read -ra PATTERNS <<< "$SUITE_FILTER" + for pattern in "${PATTERNS[@]}"; do + pattern="${pattern## }" # trim leading spaces + pattern="${pattern%% }" # trim trailing spaces + if [[ "$1" == $pattern ]]; then + return 0 + fi + done + return 1 +} + VSCODEUSERDATADIR=`mktemp -d 2>/dev/null` VSCODECRASHDIR=$ROOT/.build/crashes VSCODELOGSDIR=$ROOT/.build/logs/integration-tests -cd $ROOT +# Seed user settings to disable OS notifications (dock bounce, toast, etc.) +mkdir -p "$VSCODEUSERDATADIR/User" +cat > "$VSCODEUSERDATADIR/User/settings.json" </dev/null) --extensionDevelopmentPath=$ROOT/extensions/git --extensionTestsPath=$ROOT/extensions/git/out/test $API_TESTS_EXTRA_ARGS kill_app +fi +if should_run_suite git-base; then echo echo "### Git Base tests" echo -npm run test-extension -- -l git-base +npm run test-extension -- -l git-base "${GREP_ARGS[@]}" kill_app +fi +if should_run_suite ipynb; then echo echo "### Ipynb tests" echo -npm run test-extension -- -l ipynb +npm run test-extension -- -l ipynb "${GREP_ARGS[@]}" kill_app +fi +if should_run_suite notebook-renderers; then echo echo "### Notebook Output tests" echo -npm run test-extension -- -l notebook-renderers +npm run test-extension -- -l notebook-renderers "${GREP_ARGS[@]}" kill_app +fi +if should_run_suite configuration-editing; then echo echo "### Configuration editing tests" echo -npm run test-extension -- -l configuration-editing +npm run test-extension -- -l configuration-editing "${GREP_ARGS[@]}" kill_app +fi +if should_run_suite github-authentication; then echo echo "### GitHub Authentication tests" echo -npm run test-extension -- -l github-authentication +npm run test-extension -- -l github-authentication "${GREP_ARGS[@]}" kill_app +fi # Tests standalone (CommonJS) +if should_run_suite css; then echo echo "### CSS tests" echo -cd $ROOT/extensions/css-language-features/server && $ROOT/scripts/node-electron.sh test/index.js +cd "$ROOT/extensions/css-language-features/server" && "$ROOT/scripts/node-electron.sh" test/index.js +fi +if should_run_suite html; then echo echo "### HTML tests" echo -cd $ROOT/extensions/html-language-features/server && $ROOT/scripts/node-electron.sh test/index.js +cd "$ROOT/extensions/html-language-features/server" && "$ROOT/scripts/node-electron.sh" test/index.js +fi # Cleanup -rm -rf $VSCODEUSERDATADIR +rm -rf -- "$VSCODEUSERDATADIR" 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..e96ed8908b6a8 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(/* webpackIgnore: true */ /* @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(/* webpackIgnore: true */ /* @vite-ignore */ `${'fs'}`)).default; + const vm = (await import(/* webpackIgnore: true */ /* @vite-ignore */ `${'vm'}`)).default; + const module = (await import(/* webpackIgnore: true */ /* @vite-ignore */ `${'module'}`)).default; const filePath = URI.parse(scriptSrc).fsPath; const content = fs.readFileSync(filePath).toString(); diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index c338502d541da..72fa42df1a6a5 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -89,7 +89,7 @@ function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesh targetWindow.document.head.appendChild(clone); disposables.add(toDisposable(() => clone.remove())); - for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { + for (const rule of globalStylesheet.sheet?.cssRules ?? []) { clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); } @@ -111,16 +111,6 @@ function getSharedStyleSheet(): HTMLStyleElement { return _sharedStyleSheet; } -function getDynamicStyleSheetRules(style: HTMLStyleElement) { - if (style?.sheet?.rules) { - return style.sheet.rules; // Chrome, IE - } - if (style?.sheet?.cssRules) { - return style.sheet.cssRules; // FF - } - return []; -} - export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { if (!style || !cssText) { return; @@ -139,7 +129,7 @@ export function removeCSSRulesContainingSelector(ruleName: string, style = getSh return; } - const rules = getDynamicStyleSheetRules(style); + const rules = style.sheet?.cssRules ?? []; const toDelete: number[] = []; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; 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/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index fceb9c852cce1..3169c8326c33b 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -328,9 +328,14 @@ export class Dialog extends Disposable { // Handle keyboard events globally: Tab, Arrow-Left/Right const window = getWindow(this.container); + let sawEscapeKeyDown = false; this._register(addDisposableListener(window, 'keydown', e => { const evt = new StandardKeyboardEvent(e); + if (evt.equals(KeyCode.Escape)) { + sawEscapeKeyDown = true; + } + if (evt.equals(KeyMod.Alt)) { evt.preventDefault(); } @@ -470,7 +475,7 @@ export class Dialog extends Disposable { EventHelper.stop(e, true); const evt = new StandardKeyboardEvent(e); - if (!this.options.disableCloseAction && evt.equals(KeyCode.Escape)) { + if (!this.options.disableCloseAction && evt.equals(KeyCode.Escape) && sawEscapeKeyDown) { close(); } }, true)); 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/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index 80f9fea1f8792..aefd0b69b1ef4 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -174,9 +174,9 @@ export class FindInput extends Widget { })); // Arrow-Key support to navigate between options - const indexes = [this.caseSensitive.domNode, this.wholeWords.domNode, this.regex.domNode]; this.onkeydown(this.domNode, (event: IKeyboardEvent) => { if (event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Escape)) { + const indexes = this.getToggleDomNodes(); const index = indexes.indexOf(this.domNode.ownerDocument.activeElement); if (index >= 0) { let newIndex: number = -1; @@ -315,6 +315,23 @@ export class FindInput extends Widget { this.updateInputBoxPadding(); } + protected getToggleDomNodes(): HTMLElement[] { + const nodes: HTMLElement[] = []; + if (this.caseSensitive) { + nodes.push(this.caseSensitive.domNode); + } + if (this.wholeWords) { + nodes.push(this.wholeWords.domNode); + } + if (this.regex) { + nodes.push(this.regex.domNode); + } + for (const toggle of this.additionalToggles) { + nodes.push(toggle.domNode); + } + return nodes; + } + public setActions(actions: ReadonlyArray | undefined, actionViewItemProvider?: IActionViewItemProvider): void { this.inputBox.setActions(actions, actionViewItemProvider); } 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/codicons.ts b/src/vs/base/common/codicons.ts index b84311aa21193..e73173e2d5e12 100644 --- a/src/vs/base/common/codicons.ts +++ b/src/vs/base/common/codicons.ts @@ -47,6 +47,8 @@ export const codiconsDerived = { gitFetch: register('git-fetch', 0xec1d), lightbulbSparkleAutofix: register('lightbulb-sparkle-autofix', 0xec1f), debugBreakpointPending: register('debug-breakpoint-pending', 0xebd9), + chatImport: register('chat-import', 0xec86), + chatExport: register('chat-export', 0xec87), } as const; 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..40b2d70a318e9 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -25,6 +25,7 @@ export interface ILegacyQuotaSnapshotData { export interface IEntitlementsData extends ILegacyQuotaSnapshotData { readonly access_type_sku: string; + readonly chat_enabled: boolean; readonly assigned_date: string; readonly can_signup_for_limited: boolean; readonly copilot_plan: string; @@ -48,6 +49,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/glob.ts b/src/vs/base/common/glob.ts index 6c2b7680f8b3d..8b750c0924f97 100644 --- a/src/vs/base/common/glob.ts +++ b/src/vs/base/common/glob.ts @@ -355,9 +355,7 @@ function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): P ...options, equals: ignoreCase ? equalsIgnoreCase : (a: string, b: string) => a === b, endsWith: ignoreCase ? endsWithIgnoreCase : (str: string, candidate: string) => str.endsWith(candidate), - // TODO: the '!isLinux' part below is to keep current behavior unchanged, but it should probably be removed - // in favor of passing correct options from the caller. - isEqualOrParent: (base: string, candidate: string) => isEqualOrParent(base, candidate, !isLinux || ignoreCase) + isEqualOrParent: (base: string, candidate: string) => isEqualOrParent(base, candidate, options.ignoreCase ?? !isLinux /* preserve old behaviour for when option is not adopted */) }; // Check cache @@ -371,13 +369,13 @@ function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): P let match: RegExpExecArray | null; if (T1.test(pattern)) { parsedPattern = trivia1(pattern.substring(4), pattern, internalOptions); // common pattern: **/*.txt just need endsWith check - } else if (match = T2.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/some.txt just need basename check + } else if (match = T2.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/some.txt just need basename check parsedPattern = trivia2(match[1], pattern, internalOptions); - } else if ((options.trimForExclusions ? T3_2 : T3).test(pattern)) { // repetition of common patterns (see above) {**/*.txt,**/*.png} + } else if ((options.trimForExclusions ? T3_2 : T3).test(pattern)) { // repetition of common patterns (see above) {**/*.txt,**/*.png} parsedPattern = trivia3(pattern, internalOptions); - } else if (match = T4.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/something/else just need endsWith check + } else if (match = T4.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/something/else just need endsWith check parsedPattern = trivia4and5(match[1].substring(1), pattern, true, internalOptions); - } else if (match = T5.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: something/else just need equals check + } else if (match = T5.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: something/else just need equals check parsedPattern = trivia4and5(match[1], pattern, false, internalOptions); } 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/labels.ts b/src/vs/base/common/labels.ts index 6945ec835419f..df52f3281df3a 100644 --- a/src/vs/base/common/labels.ts +++ b/src/vs/base/common/labels.ts @@ -209,13 +209,15 @@ export function untildify(path: string, userHome: string): string { */ const ellipsis = '\u2026'; const unc = '\\\\'; +const urlSchemaRegexp = /^[^:/\\?#]+?:\/\//; const home = '~'; -export function shorten(paths: string[], pathSeparator: string = sep): string[] { +export function shorten(paths: string[], defaultPathSeparator: string = sep): string[] { const shortenedPaths: string[] = new Array(paths.length); // for every path let match = false; for (let pathIndex = 0; pathIndex < paths.length; pathIndex++) { + let pathSeparator = defaultPathSeparator; const originalPath = paths[pathIndex]; if (originalPath === '') { @@ -233,7 +235,11 @@ export function shorten(paths: string[], pathSeparator: string = sep): string[] // trim for now and concatenate unc path (e.g. \\network) or root path (/etc, ~/etc) later let prefix = ''; let trimmedPath = originalPath; - if (trimmedPath.indexOf(unc) === 0) { + if (urlSchemaRegexp.test(trimmedPath)) { + prefix = trimmedPath.substr(0, trimmedPath.indexOf('//') + 2); + trimmedPath = trimmedPath.substr(trimmedPath.indexOf('//') + 2); + pathSeparator = '/'; + } else if (trimmedPath.indexOf(unc) === 0) { prefix = trimmedPath.substr(0, trimmedPath.indexOf(unc) + unc.length); trimmedPath = trimmedPath.substr(trimmedPath.indexOf(unc) + unc.length); } else if (trimmedPath.indexOf(pathSeparator) === 0) { @@ -296,7 +302,12 @@ export function shorten(paths: string[], pathSeparator: string = sep): string[] // add ellipsis at the end if needed if (start + subpathLength < segments.length) { - result = result + pathSeparator + ellipsis; + // If the last segment is empty, preserve the trailing slash. + if (start + subpathLength === segments.length - 1 && segments[segments.length - 1] === '') { + result = result + pathSeparator; + } else { + result = result + pathSeparator + ellipsis; + } } shortenedPaths[pathIndex] = result; 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/performance.ts b/src/vs/base/common/performance.ts index ab97b75fb7e42..30cc655644ab2 100644 --- a/src/vs/base/common/performance.ts +++ b/src/vs/base/common/performance.ts @@ -24,7 +24,14 @@ function _definePolyfillMarks(timeOrigin?: number) { } return result; } - return { mark, getMarks }; + function clearMarks(prefix: string) { + for (let i = _data.length - 2; i >= 0; i -= 2) { + if (typeof _data[i] === 'string' && (_data[i] as string).startsWith(prefix)) { + _data.splice(i, 2); + } + } + } + return { mark, getMarks, clearMarks }; } declare const process: INodeProcess; @@ -42,6 +49,7 @@ interface IPerformanceTiming { interface IPerformance { mark(name: string, markOptions?: { startTime?: number }): void; + clearMarks(name?: string): void; getEntriesByType(type: string): IPerformanceEntry[]; readonly timeOrigin: number; readonly timing: IPerformanceTiming; @@ -69,6 +77,17 @@ function _define() { mark(name: string, markOptions?: { startTime?: number }) { performance.mark(name, markOptions); }, + clearMarks(prefix: string) { + const toRemove = new Set(); + for (const entry of performance.getEntriesByType('mark')) { + if (entry.name.startsWith(prefix)) { + toRemove.add(entry.name); + } + } + for (const name of toRemove) { + performance.clearMarks(name); + } + }, getMarks() { let timeOrigin = performance.timeOrigin; if (typeof timeOrigin !== 'number') { @@ -112,6 +131,11 @@ const perf = _factory(globalThis); export const mark: (name: string, markOptions?: { startTime?: number }) => void = perf.mark; +/** + * Clears all marks whose name starts with the given prefix. + */ +export const clearMarks: (prefix: string) => void = perf.clearMarks; + export interface PerformanceMark { readonly name: string; readonly startTime: number; diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 8325d6789f089..8f7a71bfa4e2f 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -76,9 +76,12 @@ export interface IProductConfiguration { readonly win32AppUserModelId?: string; readonly win32MutexName?: string; readonly win32RegValueName?: string; + readonly win32NameVersion?: string; readonly win32VersionedUpdate?: boolean; + readonly win32SiblingExeBasename?: string; readonly applicationName: string; readonly embedderIdentifier?: string; + readonly telemetryAppName?: string; readonly urlProtocol: string; readonly dataFolderName: string; // location for extensions (e.g. ~/.vscode-insiders) @@ -230,8 +233,25 @@ export interface IProductConfiguration { readonly remoteDefaultExtensionsIfInstalledLocally?: string[]; readonly extensionConfigurationPolicy?: IStringDictionary; + + readonly embedded?: IEmbeddedProductConfiguration; } +export type IEmbeddedProductConfiguration = Pick; + export interface ITunnelApplicationConfig { authenticationProviders: IStringDictionary<{ scopes: string[] }>; editorWebUrl: string; 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/labels.test.ts b/src/vs/base/test/common/labels.test.ts index dd91ac24b9761..5abd2587ddabf 100644 --- a/src/vs/base/test/common/labels.test.ts +++ b/src/vs/base/test/common/labels.test.ts @@ -61,6 +61,12 @@ suite('Labels', () => { assert.deepStrictEqual(labels.shorten(['a', 'a\\b', 'b']), ['a', 'a\\b', 'b']); assert.deepStrictEqual(labels.shorten(['', 'a', 'b', 'b\\c', 'a\\c']), ['.\\', 'a', 'b', 'b\\c', 'a\\c']); assert.deepStrictEqual(labels.shorten(['src\\vs\\workbench\\parts\\execution\\electron-browser', 'src\\vs\\workbench\\parts\\execution\\electron-browser\\something', 'src\\vs\\workbench\\parts\\terminal\\electron-browser']), ['…\\execution\\electron-browser', '…\\something', '…\\terminal\\…']); + + // url paths + assert.deepStrictEqual(labels.shorten(['https://a.com/b', 'C:\\foo\\d']), ['https://a.com/b', 'C:\\…\\d']); + assert.deepStrictEqual(labels.shorten(['https://a.com/x', 'C:\\foo\\x']), ['https://a.com/…', 'C:\\foo\\…']); + assert.deepStrictEqual(labels.shorten(['https://a.com/y/z', 'C:\\foo\\bar\\z']), ['https://a.com/y/…', 'C:\\…\\bar\\…']); + assert.deepStrictEqual(labels.shorten(['file://C:/foo/bar/z', 'C:\\foo\\bar\\z']), ['file://C:/…/bar/z', 'C:\\…\\bar\\z']); }); (isWindows ? test.skip : test)('shorten - not windows', () => { @@ -106,6 +112,19 @@ suite('Labels', () => { assert.deepStrictEqual(labels.shorten(['a', 'a/b', 'a/b/c', 'd/b/c', 'd/b']), ['a', 'a/b', 'a/b/c', 'd/b/c', 'd/b']); assert.deepStrictEqual(labels.shorten(['a', 'a/b', 'b']), ['a', 'a/b', 'b']); assert.deepStrictEqual(labels.shorten(['', 'a', 'b', 'b/c', 'a/c']), ['./', 'a', 'b', 'b/c', 'a/c']); + + // url paths + assert.deepStrictEqual(labels.shorten(['https://a.com/b']), ['https://a.com/b']); + assert.deepStrictEqual(labels.shorten(['https://a.com/', 'https://b.com/']), ['https://a.com/', 'https://b.com/']); + assert.deepStrictEqual(labels.shorten(['https://a.com/b', 'https://a.com/c']), ['https://a.com/b', 'https://a.com/c']); + assert.deepStrictEqual(labels.shorten(['https://a.com/x/y', 'https://b.com/x/y']), ['https://a.com/…', 'https://b.com/…']); + assert.deepStrictEqual(labels.shorten(['https://a.com/b', 'https://a.com/b/c']), ['https://a.com/b', 'https://a.com/…/c']); + assert.deepStrictEqual(labels.shorten(['https://a.com/b', 'http://a.com/b']), ['https://a.com/b', 'http://a.com/b']); + assert.deepStrictEqual(labels.shorten(['https://a.com/x/y/z', 'https://a.com/x/w/z']), ['https://a.com/…/y/…', 'https://a.com/…/w/…']); + assert.deepStrictEqual(labels.shorten(['https://a.com/b', '/c/d']), ['https://a.com/b', '/c/d']); + assert.deepStrictEqual(labels.shorten(['https://a.com/x', '/c/x']), ['https://a.com/…', '/c/x']); + assert.deepStrictEqual(labels.shorten(['https://a.com/x/y', '/c/x/y']), ['https://a.com/…', '/c/x/…']); + assert.deepStrictEqual(labels.shorten(['file:///foo/bar/z', '/foo/bar/z']), ['file:///foo/bar/z', '/foo/bar/z']); }); test('template', () => { diff --git a/src/vs/base/test/common/performance.test.ts b/src/vs/base/test/common/performance.test.ts new file mode 100644 index 0000000000000..9e838693f8f34 --- /dev/null +++ b/src/vs/base/test/common/performance.test.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. + *--------------------------------------------------------------------------------------------*/ +import assert from 'assert'; +import { clearMarks, getMarks, mark } from '../../common/performance.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; + +function marksFor(prefix: string) { + return getMarks().filter(m => m.name.startsWith(prefix)); +} + +// Each test uses a unique prefix via a counter to avoid singleton state leaking between tests. +let testCounter = 0; +function uniquePrefix(): string { + return `test/perf/${testCounter++}/`; +} + +suite('clearMarks', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + let prefix: string; + + setup(() => { + prefix = uniquePrefix(); + }); + + test('clears all marks with matching prefix', () => { + mark(`${prefix}a`); + mark(`${prefix}b`); + mark(`${prefix}c`); + + clearMarks(prefix); + assert.strictEqual(marksFor(prefix).length, 0); + }); + + test('does not clear marks with a different prefix', () => { + const otherPrefix = uniquePrefix(); + mark(`${prefix}a`); + mark(`${otherPrefix}b`); + + clearMarks(prefix); + + assert.strictEqual(marksFor(prefix).length, 0); + assert.strictEqual(marksFor(otherPrefix).length, 1); + + clearMarks(otherPrefix); + }); +}); diff --git a/extensions/git/extension.webpack.config.js b/src/vs/base/test/common/sinonUtils.ts similarity index 51% rename from extensions/git/extension.webpack.config.js rename to src/vs/base/test/common/sinonUtils.ts index 34f801e2eca4e..ef256b115a088 100644 --- a/extensions/git/extension.webpack.config.js +++ b/src/vs/base/test/common/sinonUtils.ts @@ -2,16 +2,9 @@ * 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 * as sinon from 'sinon'; -export const StripOutSourceMaps = ['dist/askpass-main.js']; +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..69fa1bc6e72d9 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'; @@ -50,6 +49,8 @@ import { IExtensionHostStarter, ipcExtensionHostStarterChannelName } from '../.. import { ExtensionHostStarter } from '../../platform/extensions/electron-main/extensionHostStarter.js'; import { IExternalTerminalMainService } from '../../platform/externalTerminal/electron-main/externalTerminal.js'; import { LinuxExternalTerminalService, MacExternalTerminalService, WindowsExternalTerminalService } from '../../platform/externalTerminal/node/externalTerminalService.js'; +import { ISandboxHelperMainService } from '../../platform/sandbox/electron-main/sandboxHelperService.js'; +import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper.js'; import { LOCAL_FILE_SYSTEM_CHANNEL_NAME } from '../../platform/files/common/diskFileSystemProviderClient.js'; import { IFileService } from '../../platform/files/common/files.js'; import { DiskFileSystemProviderChannel } from '../../platform/files/electron-main/diskFileSystemProviderServer.js'; @@ -122,6 +123,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'; @@ -737,6 +741,7 @@ export class CodeApplication extends Disposable { const openables: IWindowOpenable[] = []; const urls: IProtocolUrl[] = []; + for (const protocolUrl of protocolUrls) { if (!protocolUrl) { continue; // invalid @@ -744,6 +749,11 @@ export class CodeApplication extends Disposable { const windowOpenable = this.getWindowOpenableFromProtocolUrl(protocolUrl.uri); if (windowOpenable) { + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#resolveInitialProtocolUrls() agents app skipping window openable:', protocolUrl.uri.toString(true)); + continue; // Agents app: skip all window openables (file/folder/workspace) + } + if (await this.shouldBlockOpenable(windowOpenable, windowsMainService, dialogMainService)) { this.logService.trace('app#resolveInitialProtocolUrls() protocol url was blocked:', protocolUrl.uri.toString(true)); @@ -889,10 +899,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); + // Agents app: ensure the agents window is open, then let other handlers process the URL. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#handleProtocolUrl() agents 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() agents app skipping window openable:', uri.toString(true)); + return true; + } + + // Ensure agents window is open to receive the URL + const windows = await windowsMainService.openAgentsWindow({ context: OpenContext.LINK, cli: this.environmentMainService.args }); + 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 +1090,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 +1133,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)); @@ -1111,6 +1147,7 @@ export class CodeApplication extends Disposable { } else if (isLinux) { services.set(IExternalTerminalMainService, new SyncDescriptor(LinuxExternalTerminalService)); } + services.set(ISandboxHelperMainService, new SyncDescriptor(SandboxHelperService)); // Backups const backupMainService = new BackupMainService(this.environmentMainService, this.configurationService, this.logService, this.stateService); @@ -1130,7 +1167,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 }; @@ -1153,7 +1190,6 @@ export class CodeApplication extends Disposable { services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); - // Dev Only: CSS service (for ESM) services.set(ICSSDevelopmentService, new SyncDescriptor(CSSDevelopmentService, undefined, true)); @@ -1281,10 +1317,14 @@ export class CodeApplication extends Disposable { const externalTerminalChannel = ProxyChannel.fromService(accessor.get(IExternalTerminalMainService), disposables); mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel); + // Sandbox Helper + const sandboxHelperChannel = ProxyChannel.fromService(accessor.get(ISandboxHelperMainService), disposables); + mainProcessElectronServer.registerChannel('sandboxHelper', sandboxHelperChannel); + // 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 @@ -1312,9 +1352,13 @@ export class CodeApplication extends Disposable { const context = isLaunchedFromCli(process.env) ? OpenContext.CLI : OpenContext.DESKTOP; const args = this.environmentMainService.args; - // Handle sessions window first based on context - if ((process as INodeProcess).isEmbeddedApp || (args['sessions'] && this.productService.quality !== 'stable')) { - return windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); + // Handle agents window first based on context + if ((process as INodeProcess).isEmbeddedApp || (args['agents'] && this.productService.quality !== 'stable')) { + return windowsMainService.openAgentsWindow({ + context, + cli: args, + initialStartup: true + }); } // Then check for windows from protocol links to open @@ -1508,42 +1552,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..ff213c40f16dd 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -87,6 +87,8 @@ import { InspectProfilingService as V8InspectProfilingService } from '../../../p import { IV8InspectProfilingService } from '../../../platform/profiling/common/profiling.js'; import { IExtensionsScannerService } from '../../../platform/extensionManagement/common/extensionsScannerService.js'; import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js'; +import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js'; import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js'; import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js'; import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js'; @@ -134,9 +136,9 @@ 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'; +import { ILocalGitService } from '../../../platform/git/common/localGitService.js'; +import { LocalGitService } from '../../../platform/git/node/localGitService.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -327,7 +329,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,9 +406,11 @@ 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)); + // Local Git + services.set(ILocalGitService, new SyncDescriptor(LocalGitService, undefined, false /* proxied to other processes */)); + + // SSH Remote Agent Host + services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true)); return new InstantiationService(services); } @@ -476,8 +480,16 @@ 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); + + // Local Git + const localGitChannel = ProxyChannel.fromService(accessor.get(ILocalGitService), this._store); + this.server.registerChannel('localGit', localGitChannel); + + // SSH Remote Agent Host + const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store); + this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 8e29f4924766b..22350b002d906 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'; @@ -484,8 +484,23 @@ export async function main(argv: string[]): Promise { if (!args.verbose && args.status) { options['stdio'] = ['ignore', 'pipe', 'ignore']; // restore ability to see output when --status is used } - // We spawn process.execPath directly - child = spawn(process.execPath, argv.slice(2), options); + + // Figure out the app to launch: with --agents we try to launch the embedded app on Windows + let execToLaunch = process.execPath; + if (isWindows && args.agents && product.embedded?.win32SiblingExeBasename) { + const siblingExe = join(dirname(process.execPath), `${product.embedded.win32SiblingExeBasename}.exe`); + try { + if (existsSync(siblingExe) && statSync(siblingExe).isFile()) { + execToLaunch = siblingExe; + argv = argv.filter(arg => arg !== '--agents'); + } + } catch (error) { + /* may not exist on disk */ + } + } + + // We spawn the resolved executable directly + child = spawn(execToLaunch, argv.slice(2), options); } else { // On macOS, we spawn using the open command to obtain behavior // similar to if the app was launched from the dock @@ -499,8 +514,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 --agents we try to launch the embedded app + let appToLaunch = process.execPath; + if (args.agents) { + // 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 !== '--agents'); + } + } 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/quickInput/standaloneQuickInputService.ts b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts index dddb6f8f05980..4aa28793effae 100644 --- a/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts +++ b/src/vs/editor/standalone/browser/quickInput/standaloneQuickInputService.ts @@ -102,6 +102,7 @@ export class StandaloneQuickInputService implements IQuickInputService { get currentQuickInput() { return this.activeService.currentQuickInput; } get quickAccess() { return this.activeService.quickAccess; } get backButton() { return this.activeService.backButton; } + get alignment() { return this.activeService.alignment; } get onShow() { return this.activeService.onShow; } get onHide() { return this.activeService.onHide; } diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 0824dfbb53337..ace6ffc3bfd2a 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 { @@ -803,6 +803,7 @@ class StandaloneTelemetryService implements ITelemetryService { readonly sendErrorTelemetry = false; setEnabled(): void { } setExperimentProperty(): void { } + setCommonProperty(): void { } publicLog() { } publicLog2() { } publicLogError() { } @@ -852,6 +853,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 +1124,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..8ace9a35339c9 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,10 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, + private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, + private readonly _onShowSubmenu: ((item: IActionListItem) => void) | undefined, + private readonly _hasAnySubmenuActions: boolean, + private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -205,9 +223,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 { @@ -230,6 +252,14 @@ class ActionItemRenderer implements IListRenderer, IAction dom.setVisibility(!element.hideIcon, data.icon); + // Set aria-expanded for section toggle items + if (element.isSectionToggle) { + const expanded = element.group?.icon === Codicon.chevronDown; + data.container.setAttribute('aria-expanded', String(expanded)); + } else { + data.container.removeAttribute('aria-expanded'); + } + // Apply optional className - clean up previous to avoid stale classes // from virtualized row reuse if (data.previousClassName) { @@ -263,7 +293,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 +332,42 @@ 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 only for items with submenu actions + if (element.submenuActions?.length) { + data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); + data.submenuIndicator.style.display = ''; + data.submenuIndicator.style.visibility = ''; + data.elementDisposables.add(dom.addDisposableListener(data.submenuIndicator, dom.EventType.CLICK, (e) => { + e.stopPropagation(); + this._onShowSubmenu?.(element); + })); + } else if (this._hasAnySubmenuActions) { + // Reserve space for alignment when other items have submenus + data.submenuIndicator.className = 'action-list-submenu-indicator'; + data.submenuIndicator.style.display = ''; + data.submenuIndicator.style.visibility = 'hidden'; + } else { + data.submenuIndicator.className = 'action-list-submenu-indicator'; + data.submenuIndicator.style.display = 'none'; } } @@ -341,6 +407,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,68 +422,117 @@ 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; + /** * When true and filtering is enabled, focuses the filter input when the list opens. */ readonly focusFilterOnOpen?: boolean; + + /** + * When false, non-submenu items do not reserve space for the submenu chevron. + * Defaults to true for alignment consistency. + */ + readonly reserveSubmenuSpace?: 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; + private readonly _baseLineHeight = 24; + 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 _hasLaidOut = 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) { @@ -423,21 +543,17 @@ export class ActionList extends Disposable { const virtualDelegate: IListVirtualDelegate> = { getHeight: element => { - switch (element.kind) { - case ActionListItemKind.Header: - return this._headerLineHeight; - case ActionListItemKind.Separator: - return this._separatorLineHeight; - default: - return this._actionLineHeight; - } + return this._getItemHeight(element); }, getTemplateId: element => element.kind }; + const reserveSubmenuSpace = this._options?.reserveSubmenuSpace ?? true; + const hasAnySubmenuActions = reserveSubmenuSpace && 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), (item) => this._showSubmenuForItem(item), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -482,19 +598,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 +632,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 +673,7 @@ export class ActionList extends Disposable { } else { this._collapsedSections.add(section); } + this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); } @@ -609,35 +752,31 @@ 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 if (this._hasLaidOut) { + // 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 +788,6 @@ export class ActionList extends Disposable { return this._filterContainer; } - - get filterInput(): HTMLInputElement | undefined { return this._filterInput; } @@ -670,6 +807,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 +842,7 @@ export class ActionList extends Disposable { this._delegate.onHide(didCancel); this.cts.cancel(); this._hover.clear(); - this._contextViewService.hideContextView(); + this._hideSubmenu(); } clearFilter(): boolean { @@ -710,71 +855,80 @@ 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; + } + + /** + * Returns the height for an action item, using the base line height + * for items without a description when `descriptionBelow` is enabled. + */ + protected _getItemHeight(item: IActionListItem): number { + switch (item.kind) { + case ActionListItemKind.Header: + return this._headerLineHeight; + case ActionListItemKind.Separator: + return this._separatorLineHeight; + default: + if (this._options?.descriptionBelow && !item.description) { + return this._baseLineHeight; + } + 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) { + fullHeight += this._getItemHeight(item); + } + 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++) { const element = this._list.element(i); - switch (element.kind) { - case ActionListItemKind.Header: - listHeight += this._headerLineHeight; - break; - case ActionListItemKind.Separator: - listHeight += this._separatorLineHeight; - break; - default: - listHeight += this._actionLineHeight; - break; - } + listHeight += this._getItemHeight(element); } + 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._hasLaidOut = true; + 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 +938,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. @@ -800,11 +950,7 @@ export class ActionList extends Disposable { this._list.splice(0, visibleCount, allItems); let allItemsHeight = 0; for (const item of allItems) { - switch (item.kind) { - case ActionListItemKind.Header: allItemsHeight += this._headerLineHeight; break; - case ActionListItemKind.Separator: allItemsHeight += this._separatorLineHeight; break; - default: allItemsHeight += this._actionLineHeight; break; - } + allItemsHeight += this._getItemHeight(item); } this._list.layout(allItemsHeight); @@ -815,7 +961,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 +980,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(); @@ -991,10 +1118,22 @@ export class ActionList extends Disposable { } const element = e.elements[0]; - if (element.isSectionToggle) { + if (element.isSectionToggle && element.section) { this._list.setSelection([]); + const section = element.section; + queueMicrotask(() => { + this._toggleSection(section); + }); return; } + // Don't select when clicking the toolbar or submenu indicator + if (dom.isMouseEvent(e.browserEvent)) { + const target = e.browserEvent.target; + if (dom.isHTMLElement(target) && (target.closest('.action-list-item-toolbar') || 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 +1156,257 @@ 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(); + + 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` }); + } - // 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. + private _showSubmenuForItem(item: IActionListItem): void { + const index = this._list.indexOf(item); + if (index >= 0) { 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` }); + this._showSubmenuForElement(item, rowElement); } } + } - this._hover.value = newHover; + 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[] = []; + const submenuGroups = element.submenuActions!.filter((a): a is SubmenuAction => a instanceof SubmenuAction); + const groupsWithActions = submenuGroups.filter(g => g.actions.length > 0); + for (let gi = 0; gi < groupsWithActions.length; gi++) { + const group = groupsWithActions[gi]; + if (group.label) { + submenuItems.push({ + kind: ActionListItemKind.Header, + group: { title: group.label }, + label: group.label, + }); + } + for (let ci = 0; ci < group.actions.length; ci++) { + const child = group.actions[ci]; + 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: {}, + }); + } + if (gi < groupsWithActions.length - 1) { + submenuItems.push({ kind: ActionListItemKind.Separator, label: '' }); + } + } + // Also include non-SubmenuAction items directly + for (const action of element.submenuActions!) { + if (!(action instanceof SubmenuAction)) { + submenuItems.push({ + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: action.tooltip || undefined, + group: { title: '' }, + hideIcon: false, + hover: {}, + }); + } + } + + 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 +1414,13 @@ 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. + // But still maintain submenu state for items with submenu actions. const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null; if (isHoveringToolbar) { + if (!element.submenuActions?.length) { + this._cancelSubmenuShow(); + } this._list.setFocus([]); return; } @@ -1066,7 +1428,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) { @@ -1084,17 +1464,173 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { - if (e.element && e.element.isSectionToggle && e.element.section) { - const section = e.element.section; - queueMicrotask(() => this._toggleSection(section)); - return; - } if (e.element && this.focusCondition(e.element)) { this._list.setFocus([]); } } } +/** + * 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..f6e19c33c6c6d 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; @@ -160,14 +160,14 @@ } .action-widget .monaco-list-row.action .monaco-keybinding > .monaco-keybinding-key { - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-style: solid; - border-width: 1px; - border-radius: 3px; - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-style: solid; + border-width: 1px; + border-radius: 3px; + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); } /* Action bar */ @@ -217,6 +217,34 @@ 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-right: 2px; + + &:has(.description:not([style*="display: none"])) { + padding-top: 6px; + } + + .title { + line-height: 14px; + } + + .description { + display: block; + 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; @@ -227,7 +255,7 @@ display: flex; } -.action-widget .monaco-list-row .action-list-item-toolbar .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover{ +.action-widget .monaco-list-row .action-list-item-toolbar .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover { background-color: var(--vscode-toolbar-hoverBackground); } @@ -240,7 +268,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 +288,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 +305,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/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index b94a519c4f52f..0c229efff0edb 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -76,12 +76,12 @@ export class WorkbenchButtonBar extends ButtonBar { update(actions: IAction[], secondary: IAction[]): void { - const conifgProvider: IButtonConfigProvider = this._options?.buttonConfigProvider ?? (() => ({ showLabel: true })); + const configProvider: IButtonConfigProvider = this._options?.buttonConfigProvider ?? (() => ({ showLabel: true })); this._updateStore.clear(); this.clear(); - // Support instamt hover between buttons + // Support instant hover between buttons const hoverDelegate = this._updateStore.add(createInstantHoverDelegate()); for (let i = 0; i < actions.length; i++) { @@ -90,15 +90,17 @@ export class WorkbenchButtonBar extends ButtonBar { const actionOrSubmenu = actions[i]; let action: IAction; let btn: IButton; - let tooltip = actionOrSubmenu.tooltip || actionOrSubmenu.label; - if (!(actionOrSubmenu instanceof SubmenuAction)) { - tooltip = this._keybindingService.appendKeybinding(tooltip, actionOrSubmenu.id); - } - if (actionOrSubmenu instanceof SubmenuAction && actionOrSubmenu.actions.length > 0) { + let tooltip: string; + + if (actionOrSubmenu instanceof SubmenuAction && actionOrSubmenu.actions.length > 1) { const [first, ...rest] = actionOrSubmenu.actions; action = first; + + tooltip = action.tooltip || action.label; + tooltip = this._keybindingService.appendKeybinding(tooltip, action.id); + btn = this.addButtonWithDropdown({ - secondary: conifgProvider(action, i)?.isSecondary ?? secondary, + secondary: configProvider(action, i)?.isSecondary ?? secondary, actionRunner: this._actionRunner, actions: rest, contextMenuProvider: this._contextMenuService, @@ -107,9 +109,15 @@ export class WorkbenchButtonBar extends ButtonBar { small: this._options?.small, }); } else { - action = actionOrSubmenu; + action = actionOrSubmenu instanceof SubmenuAction && actionOrSubmenu.actions.length === 1 + ? actionOrSubmenu.actions[0] + : actionOrSubmenu; + + tooltip = action.tooltip || action.label; + tooltip = this._keybindingService.appendKeybinding(tooltip, action.id); + btn = this.addButton({ - secondary: conifgProvider(action, i)?.isSecondary ?? secondary, + secondary: configProvider(action, i)?.isSecondary ?? secondary, ariaLabel: tooltip, supportIcons: true, small: this._options?.small, @@ -119,9 +127,9 @@ export class WorkbenchButtonBar extends ButtonBar { btn.enabled = action.enabled; btn.checked = action.checked ?? false; btn.element.classList.add('default-colors'); - const showLabel = conifgProvider(action, i)?.showLabel ?? true; - const customClass = conifgProvider(action, i)?.customClass; - const customLabel = conifgProvider(action, i)?.customLabel; + const showLabel = configProvider(action, i)?.showLabel ?? true; + const customClass = configProvider(action, i)?.customClass; + const customLabel = configProvider(action, i)?.customLabel; if (customClass) { btn.element.classList.add(customClass); @@ -132,7 +140,7 @@ export class WorkbenchButtonBar extends ButtonBar { } else { btn.element.classList.add('monaco-text-button'); } - if (conifgProvider(action, i)?.showIcon) { + if (configProvider(action, i)?.showIcon) { if (action instanceof MenuItemAction && ThemeIcon.isThemeIcon(action.item.icon)) { if (!showLabel) { btn.icon = action.item.icon; 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..910c40db359ce 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'); @@ -99,6 +100,7 @@ export class MenuId { static readonly EditorTabsBarShowTabsSubmenu = new MenuId('EditorTabsBarShowTabsSubmenu'); static readonly EditorTabsBarShowTabsZenModeSubmenu = new MenuId('EditorTabsBarShowTabsZenModeSubmenu'); static readonly EditorActionsPositionSubmenu = new MenuId('EditorActionsPositionSubmenu'); + static readonly EditorRenderWhitespaceSubmenu = new MenuId('EditorRenderWhitespaceSubmenu'); static readonly EditorSplitMoveSubmenu = new MenuId('EditorSplitMoveSubmenu'); static readonly ExplorerContext = new MenuId('ExplorerContext'); static readonly ExplorerContextShare = new MenuId('ExplorerContextShare'); @@ -169,6 +171,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 +258,16 @@ 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 ChatEditingSessionChangesFileHeaderToolbar = new MenuId('ChatEditingSessionChangesFileHeaderToolbar'); static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent'); static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk'); static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell'); @@ -304,6 +313,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/agentClientUri.ts b/src/vs/platform/agentHost/common/agentClientUri.ts new file mode 100644 index 0000000000000..852cec896c58d --- /dev/null +++ b/src/vs/platform/agentHost/common/agentClientUri.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** + * The URI scheme for accessing client-side files from the agent host. + * + * This is the inverse of {@link AGENT_HOST_SCHEME}: the agent host uses + * this scheme to address files that live on the connected client. + * + * ``` + * vscode-agent-client://[clientId]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * For example, `file:///Users/user/plugins/my-plugin` on client `client-1` becomes: + * ``` + * vscode-agent-client://client-1/file/-/Users/user/plugins/my-plugin + * ``` + */ +export const AGENT_CLIENT_SCHEME = 'vscode-agent-client'; + +/** + * Wraps a client-side URI into a {@link AGENT_CLIENT_SCHEME} URI that + * can be resolved through the agent host's client filesystem provider. + * + * @param originalUri The URI on the client (e.g. `file:///path`) + * @param clientId The client identifier (from the protocol `clientId`) + */ +export function toAgentClientUri(originalUri: URI, clientId: string): URI { + const originalAuthority = originalUri.authority || '-'; + return URI.from({ + scheme: AGENT_CLIENT_SCHEME, + authority: clientId, + path: `/${originalUri.scheme}/${originalAuthority}${originalUri.path}`, + }); +} + +/** + * Extracts the original client-side URI from a {@link AGENT_CLIENT_SCHEME} URI. + * + * The inverse of {@link toAgentClientUri}. + */ +export function fromAgentClientUri(agentClientUri: URI): URI { + const path = agentClientUri.path; + + const schemeEnd = path.indexOf('/', 1); + if (schemeEnd === -1) { + return URI.from({ scheme: 'file', path }); + } + + const originalScheme = path.substring(1, schemeEnd); + + const authorityEnd = path.indexOf('/', schemeEnd + 1); + if (authorityEnd === -1) { + const originalAuthority = path.substring(schemeEnd + 1); + return URI.from({ scheme: originalScheme, authority: originalAuthority === '-' ? '' : 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, + }); +} diff --git a/src/vs/platform/agentHost/common/agentHostClientFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostClientFileSystemProvider.ts new file mode 100644 index 0000000000000..94f4551f73234 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientFileSystemProvider.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AHPFileSystemProvider } from './agentHostFileSystemProvider.js'; +import { fromAgentClientUri } from './agentClientUri.js'; + +/** + * Read-only filesystem provider for accessing client-side files from the + * agent host. Registered under the `vscode-agent-client` scheme. + * + * This is the inverse of {@link AgentHostFileSystemProvider}: where that + * provider lets a client read agent host files, this one lets the agent + * host read files from a connected client. + * + * ``` + * vscode-agent-client://[clientId]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + * + * Connections are registered per client ID. The connection implementation + * must proxy `browseDirectory`/`fetchContent` calls back to the client + * (e.g. via a reverse JSON-RPC request over the WebSocket transport). + */ +export class AgentHostClientFileSystemProvider extends AHPFileSystemProvider { + + protected _decodeUri(resource: URI): URI { + return fromAgentClientUri(resource); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts new file mode 100644 index 0000000000000..844d4c02c4177 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.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. + *--------------------------------------------------------------------------------------------*/ + +import { decodeBase64, 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 { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; +import { type IAgentConnection } from './agentService.js'; +import { ContentEncoding, type IDirectoryEntry, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult } from './state/protocol/commands.js'; + +/** + * Interface for performing resource operations on a remote endpoint. + * + * Both {@link IAgentConnection} (client→server) and client-exposed + * filesystems (server→client) satisfy this contract. + */ +export interface IRemoteFilesystemConnection { + resourceList(uri: URI): Promise; + resourceRead(uri: URI): Promise; + resourceWrite(params: IResourceWriteParams): Promise; + resourceDelete(params: IResourceDeleteParams): Promise; + resourceMove(params: IResourceMoveParams): Promise; +} + +/** + * 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; +} + +// ---- Abstract base ---------------------------------------------------------- + +/** + * {@link IFileSystemProvider} that proxies filesystem operations + * through a {@link IRemoteFilesystemConnection}. + * + * URIs encode the original scheme and authority in the path so any remote + * resource can be represented. Subclasses provide the URI decode function + * and scheme-specific helpers. + * + * Individual connections are identified by the URI's authority component. + */ +export abstract class AHPFileSystemProvider extends Disposable implements IFileSystemProvider { + + readonly capabilities = + FileSystemProviderCapabilities.PathCaseSensitive | + FileSystemProviderCapabilities.FileReadWrite; + + 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 a connection. + * Returns a disposable that unregisters the mapping. + */ + registerAuthority(authority: string, connection: IRemoteFilesystemConnection): IDisposable { + this._authorityToConnection.set(authority, connection); + return toDisposable(() => this._authorityToConnection.delete(authority)); + } + + /** Decode a provider URI back to the original URI for the remote endpoint. */ + protected abstract _decodeUri(resource: URI): URI; + + watch(): IDisposable { + return Disposable.None; + } + + async stat(resource: URI): Promise { + const path = resource.path; + + if (path === '/' || path === '') { + return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + const decoded = this._decodeUri(resource); + if (decoded.scheme === 'session-db') { + return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + + if (decoded.path === '/' || decoded.path === '') { + return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly }; + } + + 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]); + } + + async readFile(resource: URI): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + const result = await connection.resourceRead(originalUri); + if (result.encoding === ContentEncoding.Base64) { + return decodeBase64(result.data).buffer; + } + 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 { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceWrite({ + uri: originalUri.toString(), + data: VSBuffer.wrap(content).toString(), + encoding: ContentEncoding.Utf8, + }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } + } + + async mkdir(): Promise { + throw createFileSystemProviderError('mkdir not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(resource: URI, opts: IFileDeleteOptions): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceDelete({ uri: originalUri.toString(), recursive: opts.recursive }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } + } + + async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { + const connection = this._getConnection(from.authority); + try { + const originalFrom = this._decodeUri(from); + const originalTo = this._decodeUri(to); + await connection.resourceMove({ source: originalFrom.toString(), destination: originalTo.toString(), failIfExists: !opts.overwrite }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } + } + + // ---- Internals ---------------------------------------------------------- + + private _getConnection(authority: string): IRemoteFilesystemConnection { + 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 = this._decodeUri(resource); + const result = await connection.resourceList(originalUri); + return result.entries; + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.Unavailable, + ); + } + } +} + +// ---- Agent Host filesystem (client reads agent host files) ------------------ + +/** + * Filesystem provider for accessing agent host files from the + * client side. Registered under the `vscode-agent-host` scheme. + * + * ``` + * vscode-agent-host://[connectionAuthority]/[originalScheme]/[originalAuthority]/[originalPath] + * ``` + */ +export class AgentHostFileSystemProvider extends AHPFileSystemProvider { + protected _decodeUri(resource: URI): URI { + return fromAgentHostUri(resource); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemService.ts b/src/vs/platform/agentHost/common/agentHostFileSystemService.ts new file mode 100644 index 0000000000000..ddff1d24824aa --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostFileSystemService.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. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { IFileService } from '../../files/common/files.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILabelService } from '../../label/common/label.js'; +import { AgentHostFileSystemProvider, type IRemoteFilesystemConnection } from './agentHostFileSystemProvider.js'; +import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME } from './agentHostUri.js'; + +export type { IRemoteFilesystemConnection } from './agentHostFileSystemProvider.js'; + +export const IAgentHostFileSystemService = createDecorator('agentHostFileSystemService'); + +export interface IAgentHostFileSystemService { + readonly _serviceBrand: undefined; + + /** + * Register a mapping from a URI authority to a connection so that + * `vscode-agent-host://[authority]/…` URIs resolve through this connection. + */ + registerAuthority(authority: string, connection: IRemoteFilesystemConnection): IDisposable; +} + +class AgentHostFileSystemService extends Disposable implements IAgentHostFileSystemService { + declare readonly _serviceBrand: undefined; + + private readonly _fsProvider: AgentHostFileSystemProvider; + + constructor( + @IFileService fileService: IFileService, + @ILabelService labelService: ILabelService, + ) { + super(); + + this._fsProvider = this._register(new AgentHostFileSystemProvider()); + this._register(fileService.registerProvider(AGENT_HOST_SCHEME, this._fsProvider)); + this._register(labelService.registerFormatter(AGENT_HOST_LABEL_FORMATTER)); + } + + registerAuthority(authority: string, connection: IRemoteFilesystemConnection): IDisposable { + return this._fsProvider.registerAuthority(authority, connection); + } +} + +registerSingleton(IAgentHostFileSystemService, AgentHostFileSystemService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts new file mode 100644 index 0000000000000..157277a921357 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostUri.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 { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { Schemas } from '../../../base/common/network.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' && originalUri.scheme === Schemas.file) { + 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/agentPluginManager.ts b/src/vs/platform/agentHost/common/agentPluginManager.ts new file mode 100644 index 0000000000000..4970318c22ea6 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentPluginManager.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'; +import type { ICustomizationRef, ISessionCustomization } from './state/sessionState.js'; + +export const IAgentPluginManager = createDecorator('agentPluginManager'); + +/** + * A synced customization with its local plugin directory (when available). + */ +export interface ISyncedCustomization { + /** The session customization with loading/error status. */ + readonly customization: ISessionCustomization; + /** Local plugin directory URI, defined when the sync was successful. */ + readonly pluginDir?: URI; +} + +/** + * Manages Open Plugin directories for agent backends. + * + * Shared across agents and sessions. Syncs client-provided customization + * references to local disk, tracking nonces to avoid redundant copies. + * Concurrent syncs of the same plugin URI are serialized internally. + */ +export interface IAgentPluginManager { + readonly _serviceBrand: undefined; + + /** + * Syncs a set of client-provided customization refs to local storage. + * + * Each ref is copied to a local directory, respecting nonce-based + * caching. The optional {@link progress} callback fires as individual + * customizations complete or fail, allowing callers to publish + * incremental status updates. + * + * Concurrent calls for the same plugin URI are serialized so that + * overlapping syncs do not clobber each other. + * + * @returns Final status for every customization, with `pluginDir` + * defined when the sync was successful. + */ + syncCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (status: ISessionCustomization[]) => void): Promise; +} diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts new file mode 100644 index 0000000000000..8d2b31a2a1775 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -0,0 +1,551 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISyncedCustomization } from './agentPluginManager.js'; +import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; +import { AttachmentType, type ICustomizationRef, type IPendingMessage, 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 local 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?: URI; +} + +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?: URI; + /** Fork from an existing session at a specific turn index. */ + readonly fork?: { readonly session: URI; readonly turnIndex: number }; +} + +/** 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; + /** Kind of permission being requested (e.g. `'write'`, `'read'`). */ + readonly permissionKind?: string; + /** File path associated with the permission request. */ + readonly permissionPath?: string; +} + +/** Streaming reasoning/thinking content from the assistant. */ +export interface IAgentReasoningEvent extends IAgentProgressEventBase { + readonly type: 'reasoning'; + readonly content: string; +} + +/** A steering message was consumed (sent to the model). */ +export interface IAgentSteeringConsumedEvent extends IAgentProgressEventBase { + readonly type: 'steering_consumed'; + readonly id: string; +} + +export type IAgentProgressEvent = + | IAgentDeltaEvent + | IAgentMessageEvent + | IAgentIdleEvent + | IAgentToolStartEvent + | IAgentToolReadyEvent + | IAgentToolCompleteEvent + | IAgentTitleChangedEvent + | IAgentErrorEvent + | IAgentUsageEvent + | IAgentReasoningEvent + | IAgentSteeringConsumedEvent; + +// ---- 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; + + /** + * Called when the session's pending (steering) message changes. + * The agent harness decides how to react — e.g. inject steering + * mid-turn via `mode: 'immediate'`. + * + * Queued messages are consumed on the server side and are not + * forwarded to the agent; `queuedMessages` will always be empty. + */ + setPendingMessages?(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void; + + /** 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; + + /** + * Truncate a session's history. If `turnIndex` is provided (0-based), keeps + * turns up to and including that turn. If omitted, all turns are removed. + * Optional — not all providers support truncation. + */ + truncateSession?(session: URI, turnIndex?: number): Promise; + + /** + * Fork a session at a specific turn, creating a new session on disk + * with the source session's history up to and including the specified turn. + * Optional — not all providers support forking. + * + * @param turnIndex 0-based turn index to fork at. + * @returns The new session's raw ID. + */ + forkSession?(sourceSession: URI, newSessionId: string, turnIndex: number): Promise; + + /** + * Receives client-provided customization refs and syncs them (e.g. copies + * plugin files to local storage). Returns per-customization status with + * local plugin directories. + * + * The agent MAY defer a client restart until all active sessions are idle. + */ + setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise; + + /** + * Notifies the agent that a customization has been toggled on or off. + * The agent MAY restart its client before the next message is sent. + */ + setCustomizationEnabled(uri: string, enabled: boolean): void; + + /** 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. + */ + resourceList(uri: URI): Promise; + + /** + * Read stored content by URI from the agent host (e.g. file edit snapshots, + * or reading files from the remote filesystem). + */ + resourceRead(uri: URI): Promise; + + /** + * Write content to a file on the agent host's filesystem. + * Used for undo/redo operations on file edits. + */ + resourceWrite(params: IResourceWriteParams): Promise; + + /** + * Copy a resource from one URI to another on the agent host's filesystem. + */ + resourceCopy(params: IResourceCopyParams): Promise; + + /** + * Delete a resource at a URI on the agent host's filesystem. + */ + resourceDelete(params: IResourceDeleteParams): Promise; + + /** + * Move (rename) a resource from one URI to another on the agent host's filesystem. + */ + resourceMove(params: IResourceMoveParams): 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; + + /** Allocate the next client sequence number for action dispatch on this connection. */ + nextClientSeq(): number; +} + +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..143b0b9a3ab27 --- /dev/null +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** Connection status for a remote agent host. */ +export const enum RemoteAgentHostConnectionStatus { + Connected = 'connected', + Connecting = 'connecting', + Disconnected = 'disconnected', +} + +/** 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; + /** SSH config host alias — if set, the tunnel is re-established on startup. */ + readonly sshConfigHost?: 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; + + /** + * Forcefully reconnect to a configured remote host. + * Tears down any existing connection and starts a fresh connect attempt + * with reset backoff. + */ + reconnect(address: string): void; + + /** + * Register a pre-connected SSH agent connection. + * Used by the SSH service to inject relay-backed connections + * without going through the WebSocket connect flow. + */ + addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise; +} + +/** Metadata about a single remote connection. */ +export interface IRemoteAgentHostConnectionInfo { + readonly address: string; + readonly name: string; + readonly clientId: string; + readonly defaultDirectory?: string; + readonly status: RemoteAgentHostConnectionStatus; +} + +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 { } + reconnect(_address: string): void { } + async addSSHConnection(): Promise { + throw new Error('Remote agent host connections are not supported in this environment.'); + } +} + +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..cc9469b5f00f3 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, IReference } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { FileEditKind } from './state/sessionState.js'; + +export const ISessionDataService = createDecorator('sessionDataService'); + +// ---- File-edit types ---------------------------------------------------- + +/** + * Lightweight metadata for a file edit. Returned by {@link ISessionDatabase.getFileEdits} + * without the (potentially large) file content blobs. + */ +export interface IFileEditRecord { + /** The turn that owns this file edit. */ + turnId: string; + /** The tool call that produced this edit. */ + toolCallId: string; + /** Primary file path (after-path for edits/creates/renames, before-path for deletes). */ + filePath: string; + /** The kind of file operation. */ + kind: FileEditKind; + /** For renames, the original file path before the move. */ + originalPath?: string; + /** Number of lines added (informational, for diff metadata). */ + addedLines: number | undefined; + /** Number of lines removed (informational, for diff metadata). */ + removedLines: number | undefined; +} + +/** + * The before/after content blobs for a single file edit. + * Retrieved on demand via {@link ISessionDatabase.readFileEditContent}. + * + * For creates, `beforeContent` is absent. + * For deletes, `afterContent` is absent. + */ +export interface IFileEditContent { + /** File content before the edit. Absent for file creations. */ + beforeContent?: Uint8Array; + /** File content after the edit. Absent for file deletions. */ + afterContent?: Uint8Array; +} + +// ---- Session database --------------------------------------------------- + +/** + * A disposable handle to a per-session SQLite database backed by + * `@vscode/sqlite3`. + * + * Callers obtain an instance via {@link ISessionDataService.openDatabase} and + * **must** dispose it when finished to close the underlying database connection. + */ +export interface ISessionDatabase extends IDisposable { + /** + * Create a turn record. Must be called before storing file edits that + * reference this turn. + */ + createTurn(turnId: string): Promise; + + /** + * Delete a turn and all of its associated file edits (cascade). + */ + deleteTurn(turnId: string): Promise; + + /** + * Store a file-edit snapshot (metadata + content) for a tool invocation + * within a turn. + * + * If a record for the same `toolCallId` and `filePath` already exists + * it is replaced. + */ + storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise; + + /** + * Retrieve file-edit metadata for the given tool call IDs. + * Content blobs are **not** included — use {@link readFileEditContent} + * to fetch them on demand. Results are returned in insertion order. + */ + getFileEdits(toolCallIds: string[]): Promise; + + /** + * Read the before/after content blobs for a single file edit. + * Returns `undefined` if no edit exists for the given key. + */ + readFileEditContent(toolCallId: string, filePath: string): Promise; + + // ---- Session metadata ------------------------------------------------ + + /** + * Read a metadata value by key. + * Returns `undefined` if no value has been stored for the key. + */ + getMetadata(key: string): Promise; + + /** + * Store a metadata key-value pair. Overwrites any existing value for the key. + */ + setMetadata(key: string, value: string): Promise; + + /** + * Close the database connection. After calling this method, the object is + * considered disposed and all other methods will reject with an error. + */ + close(): Promise; +} + +/** + * 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; + + /** + * Opens (or creates) a per-session SQLite database. The database file is + * stored at `{sessionDataDir}/session.db`. Migrations are applied + * automatically on first use. + * + * Returns a ref-counted reference. Multiple callers for the same session + * share the same underlying connection. The connection is closed when + * the last reference is disposed. + */ + openDatabase(session: URI): IReference; + + /** + * Opens an existing per-session database **only if the database file + * already exists on disk**. Returns `undefined` when no database has + * been created yet, avoiding the side effect of materializing empty + * database files during read-only operations like listing sessions. + */ + tryOpenDatabase(session: URI): Promise | undefined>; + + /** + * 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/sshConfigParsing.ts b/src/vs/platform/agentHost/common/sshConfigParsing.ts new file mode 100644 index 0000000000000..858d7fe2c7b7e --- /dev/null +++ b/src/vs/platform/agentHost/common/sshConfigParsing.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ISSHResolvedConfig } from './sshRemoteAgentHost.js'; + +/** Strip inline comments from an SSH config value. */ +export function stripSSHComment(s: string): string { + const idx = s.indexOf(' #'); + return idx !== -1 ? s.substring(0, idx).trim() : s; +} + +/** + * Extract Host aliases from SSH config content (without following Includes). + */ +export function parseSSHConfigHostEntries(content: string): string[] { + const hosts: string[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const hostMatch = trimmed.match(/^Host\s+(.+)$/i); + if (hostMatch) { + const hostValue = stripSSHComment(hostMatch[1]); + for (const h of hostValue.split(/\s+/)) { + if (!h.includes('*') && !h.includes('?') && !h.startsWith('!')) { + hosts.push(h); + } + } + } + } + return hosts; +} + +/** + * Parse `ssh -G` output into a resolved config object. + */ +export function parseSSHGOutput(stdout: string): ISSHResolvedConfig { + const map = new Map(); + const identityFiles: string[] = []; + for (const line of stdout.split('\n')) { + const spaceIdx = line.indexOf(' '); + if (spaceIdx === -1) { + continue; + } + const key = line.substring(0, spaceIdx).toLowerCase(); + const value = line.substring(spaceIdx + 1).trim(); + if (key === 'identityfile') { + identityFiles.push(value); + } else { + map.set(key, value); + } + } + + return { + hostname: map.get('hostname') ?? '', + user: map.get('user') || undefined, + port: parseInt(map.get('port') ?? '22', 10), + identityFile: identityFiles, + forwardAgent: map.get('forwardagent') === 'yes', + }; +} diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts new file mode 100644 index 0000000000000..857e30a31220c --- /dev/null +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.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 { Event } from '../../../base/common/event.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ISSHRemoteAgentHostService = createDecorator('sshRemoteAgentHostService'); + +/** + * IPC channel name for the main-process SSH service. + */ +export const SSH_REMOTE_AGENT_HOST_CHANNEL = 'sshRemoteAgentHost'; + +export const enum SSHAuthMethod { + /** Use the local SSH agent for key-based auth. */ + Agent = 'agent', + /** Authenticate with an explicit private key file. */ + KeyFile = 'keyFile', + /** Authenticate with a password. */ + Password = 'password', +} + +export interface ISSHAgentHostConfig { + /** Remote hostname or IP. */ + readonly host: string; + /** SSH port (default 22). */ + readonly port?: number; + /** Username on the remote machine. */ + readonly username: string; + /** Authentication method. */ + readonly authMethod: SSHAuthMethod; + /** Path to the private key file (when {@link authMethod} is KeyFile). */ + readonly privateKeyPath?: string; + /** Password string (when {@link authMethod} is Password). */ + readonly password?: string; + /** Display name for this connection. */ + readonly name: string; + /** SSH config host alias (e.g. "robfast2") for reconnection on restart. */ + readonly sshConfigHost?: string; + /** Dev override: custom command to start the remote agent host instead of the default CLI. */ + readonly remoteAgentHostCommand?: string; +} + +/** + * A sanitized view of the SSH config that omits secret material + * (password, private key path). Exposed on active connections so + * consumers can inspect connection metadata without accessing credentials. + */ +export type ISSHAgentHostConfigSanitized = Omit; + +export interface ISSHAgentHostConnection extends IDisposable { + /** The SSH config used to establish this connection (secrets stripped). */ + readonly config: ISSHAgentHostConfigSanitized; + /** The connection address (e.g. `ssh:myhost` or `user@host:22`) registered with IRemoteAgentHostService. */ + readonly localAddress: string; + /** The display name. */ + readonly name: string; + /** Fires when this SSH connection is closed or lost. */ + readonly onDidClose: Event; +} + +/** + * Manages SSH connections that bootstrap a remote agent host process. + * + * Each connection SSHs into a remote machine, ensures the VS Code CLI + * is installed, starts `code agent-host`, and creates a WebSocket relay + * over the SSH channel. Messages are forwarded between the renderer and + * the remote agent host via IPC through the shared process. + */ +export interface ISSHRemoteAgentHostService { + readonly _serviceBrand: undefined; + + /** Fires when the set of active SSH connections changes. */ + readonly onDidChangeConnections: Event; + + /** Progress messages during connect. */ + readonly onDidReportConnectProgress: Event; + + /** Currently active SSH-bootstrapped connections. */ + readonly connections: readonly ISSHAgentHostConnection[]; + + /** + * Bootstrap a remote agent host over SSH. + * + * 1. Opens an SSH connection to the remote host + * 2. Downloads and installs the VS Code CLI if needed + * 3. Starts `code agent-host` + * 4. Creates a WebSocket relay over the SSH channel + * 5. Registers the connection with {@link IRemoteAgentHostService} + * + * Resolves with the connection handle once the agent host is reachable. + */ + connect(config: ISSHAgentHostConfig): Promise; + + /** + * Disconnect an SSH-bootstrapped connection by host address. + * Tears down the SSH tunnel, stops the remote agent host, and + * removes the entry from {@link IRemoteAgentHostService}. + */ + disconnect(host: string): Promise; + + /** List SSH config host aliases (excluding wildcards). */ + listSSHConfigHosts(): Promise; + + /** Resolve full SSH config for a host via `ssh -G`. */ + resolveSSHConfig(host: string): Promise; + + /** + * Re-establish an SSH tunnel on startup for a previously connected host. + * Returns the new local forwarded address and registers it. + */ + reconnect(sshConfigHost: string, name: string): Promise; +} +/** + * Serializable result from a successful SSH connect operation. + * Returned over IPC from the main process. + */ +export interface ISSHConnectResult { + /** Unique identifier for this connection's relay channel. */ + readonly connectionId: string; + /** Display-friendly address (e.g. "ssh:robfast2"). */ + readonly address: string; + readonly name: string; + readonly connectionToken: string | undefined; + readonly config: ISSHAgentHostConfigSanitized; + /** SSH config host alias for reconnection on restart. */ + readonly sshConfigHost?: string; +} + +/** + * Resolved SSH configuration for a host, obtained from `ssh -G`. + */ +export interface ISSHResolvedConfig { + readonly hostname: string; + readonly user: string | undefined; + readonly port: number; + readonly identityFile: string[]; + readonly forwardAgent: boolean; +} + +export interface ISSHConnectProgress { + readonly connectionKey: string; + readonly message: string; +} + +/** + * A message relayed from a remote agent host through the SSH tunnel. + * The shared process acts as a WebSocket proxy, forwarding JSON messages + * bidirectionally between the SSH channel and the renderer via IPC. + */ +export interface ISSHRelayMessage { + readonly connectionId: string; + readonly data: string; +} + +/** + * Main-process service that performs the actual SSH work. + * The renderer calls this over IPC and handles registration + * with {@link IRemoteAgentHostService} locally. + */ +export const ISSHRemoteAgentHostMainService = createDecorator('sshRemoteAgentHostMainService'); + +export interface ISSHRemoteAgentHostMainService { + readonly _serviceBrand: undefined; + + /** Fires when the set of active SSH connections changes. */ + readonly onDidChangeConnections: Event; + + /** Fires when a connection is closed from the shared process side. */ + readonly onDidCloseConnection: Event; + + /** Progress messages during connect (e.g. "Installing CLI..."). */ + readonly onDidReportConnectProgress: Event; + + /** Fires when a message is received from a remote agent host via the SSH relay. */ + readonly onDidRelayMessage: Event; + + /** Fires when a relay connection to a remote agent host closes. */ + readonly onDidRelayClose: Event; + + /** + * Bootstrap a remote agent host over SSH. Returns serializable + * connection info for the renderer to register. + */ + connect(config: ISSHAgentHostConfig): Promise; + + /** + * Send a message to a remote agent host through the SSH relay. + */ + relaySend(connectionId: string, message: string): Promise; + + /** + * Disconnect an SSH-bootstrapped connection by host address. + */ + disconnect(host: string): Promise; + + /** List SSH config host aliases (excluding wildcards). */ + listSSHConfigHosts(): Promise; + + /** Resolve full SSH config for a host via `ssh -G`. */ + resolveSSHConfig(host: string): Promise; + + /** + * Re-establish an SSH tunnel for a previously connected host. + * Resolves the SSH config alias, connects, and returns fresh + * connection info with a new local forwarded port. + */ + reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): 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/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version new file mode 100644 index 0000000000000..b2f37b431b132 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -0,0 +1 @@ +b13578c 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..ab09368f19f11 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +// 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, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction } 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 + | ISessionPendingMessageSetAction + | ISessionPendingMessageRemovedAction + | ISessionQueuedMessagesReorderedAction + | ISessionCustomizationsChangedAction + | ISessionCustomizationToggledAction + | ISessionTruncatedAction + ; + +/** Union of session actions that clients may dispatch. */ +export type IClientSessionAction = + | ISessionTurnStartedAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionTurnCancelledAction + | ISessionTitleChangedAction + | ISessionModelChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + | ISessionPendingMessageSetAction + | ISessionPendingMessageRemovedAction + | ISessionQueuedMessagesReorderedAction + | ISessionCustomizationToggledAction + | ISessionTruncatedAction + ; + +/** Union of session actions that only the server may produce. */ +export type IServerSessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionTurnCompleteAction + | ISessionErrorAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionServerToolsChangedAction + | ISessionCustomizationsChangedAction + ; + +// ─── 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]: true, + [ActionType.SessionUsage]: false, + [ActionType.SessionReasoning]: false, + [ActionType.SessionModelChanged]: true, + [ActionType.SessionServerToolsChanged]: false, + [ActionType.SessionActiveClientChanged]: true, + [ActionType.SessionActiveClientToolsChanged]: true, + [ActionType.SessionPendingMessageSet]: true, + [ActionType.SessionPendingMessageRemoved]: true, + [ActionType.SessionQueuedMessagesReordered]: true, + [ActionType.SessionCustomizationsChanged]: false, + [ActionType.SessionCustomizationToggled]: true, + [ActionType.SessionTruncated]: 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..9a9b6cfb8719a --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -0,0 +1,694 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization } 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', + SessionPendingMessageSet = 'session/pendingMessageSet', + SessionPendingMessageRemoved = 'session/pendingMessageRemoved', + SessionQueuedMessagesReordered = 'session/queuedMessagesReordered', + SessionCustomizationsChanged = 'session/customizationsChanged', + SessionCustomizationToggled = 'session/customizationToggled', + SessionTruncated = 'session/truncated', +} + +// ─── 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; + /** If this turn was auto-started from a queued message, the ID of that message */ + queuedMessageId?: string; +} + +/** + * 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. Fired by the server when the title is auto-generated + * from conversation, or dispatched by a client to rename a session. + * + * @category Session Actions + * @clientDispatchable + * @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[]; +} + +// ─── Customization Actions ─────────────────────────────────────────────────── + +/** + * The session's customizations have changed. + * + * Full-replacement semantics: the `customizations` array replaces the + * previous `customizations` entirely. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionCustomizationsChangedAction { + type: ActionType.SessionCustomizationsChanged; + /** Session URI */ + session: URI; + /** Updated customization list (full replacement) */ + customizations: ISessionCustomization[]; +} + +/** + * A client toggled a customization on or off. + * + * The server locates the customization by `uri` in the session's + * customization list and sets its `enabled` flag. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionCustomizationToggledAction { + type: ActionType.SessionCustomizationToggled; + /** Session URI */ + session: URI; + /** The URI of the customization to toggle */ + uri: URI; + /** Whether to enable or disable the customization */ + enabled: boolean; +} + +// ─── Truncation ────────────────────────────────────────────────────────────── + +/** + * Truncates a session's history. If `turnId` is provided, all turns after that + * turn are removed and the specified turn is kept. If `turnId` is omitted, all + * turns are removed. + * + * If there is an active turn it is silently dropped and the session status + * returns to `idle`. + * + * Common use-case: truncate old data then dispatch a new + * `session/turnStarted` with an edited message. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionTruncatedAction { + type: ActionType.SessionTruncated; + /** Session URI */ + session: URI; + /** Keep turns up to and including this turn. Omit to clear all turns. */ + turnId?: string; +} + +// ─── Pending Message Actions ───────────────────────────────────────────────── + +/** + * A pending message was set (upsert semantics: creates or replaces). + * + * For steering messages, this always replaces the single steering message. + * For queued messages, if a message with the given `id` already exists it is + * updated in place; otherwise it is appended to the queue. If the session is + * idle when a queued message is set, the server SHOULD immediately consume it + * and start a new turn. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionPendingMessageSetAction { + type: ActionType.SessionPendingMessageSet; + /** Session URI */ + session: URI; + /** Whether this is a steering or queued message */ + kind: PendingMessageKind; + /** Unique identifier for this pending message */ + id: string; + /** The message content */ + userMessage: IUserMessage; +} + +/** + * A pending message was removed (steering or queued). + * + * Dispatched by clients to cancel a pending message, or by the server when + * it consumes a message (e.g. starting a turn from a queued message or + * injecting a steering message into the current turn). + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionPendingMessageRemovedAction { + type: ActionType.SessionPendingMessageRemoved; + /** Session URI */ + session: URI; + /** Whether this is a steering or queued message */ + kind: PendingMessageKind; + /** Identifier of the pending message to remove */ + id: string; +} + +/** + * Reorder the queued messages. + * + * The `order` array contains the IDs of queued messages in their new + * desired order. IDs not present in the current queue are ignored. + * Queued messages whose IDs are absent from `order` are appended at + * the end in their original relative order (so a client with a stale + * view of the queue never silently drops messages). + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionQueuedMessagesReorderedAction { + type: ActionType.SessionQueuedMessagesReordered; + /** Session URI */ + session: URI; + /** Queued message IDs in the desired order */ + order: string[]; +} + +// ─── 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 + | ISessionPendingMessageSetAction + | ISessionPendingMessageRemovedAction + | ISessionQueuedMessagesReorderedAction + | ISessionCustomizationsChangedAction + | ISessionCustomizationToggledAction + | ISessionTruncatedAction; 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..fd67388ee2124 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -0,0 +1,640 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +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" } } + * ``` + */ +/** + * Identifies a source session and turn to fork from. + * + * When provided in `createSession`, the server populates the new session with + * content from the source session up to and including the response of the + * specified turn. + */ +export interface ISessionForkSource { + /** URI of the existing session to fork from */ + session: URI; + /** Turn ID in the source session; content up to and including this turn's response is copied */ + turnId: string; +} + +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; + /** + * Fork from an existing session. The new session is populated with content + * from the source session up to and including the specified turn's response. + */ + fork?: ISessionForkSource; +} + +// ─── 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[]; +} + +// ─── resourceRead ──────────────────────────────────────────────────────── + +/** + * Encoding of fetched content data. + * + * @category Commands + */ +export const enum ContentEncoding { + Base64 = 'base64', + Utf8 = 'utf-8', +} + +/** + * Reads the content of a resource by URI. + * + * 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 resourceRead + * @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": "resourceRead", + * "params": { "uri": "copilot://content/img-1" } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 10, "result": { + * "data": "iVBORw0KGgo...", + * "encoding": "base64", + * "contentType": "image/png" + * }} + * ``` + */ +export interface IResourceReadParams { + /** Content URI from a `ContentRef` */ + uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; +} + +/** + * Result of the `resourceRead` 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 IResourceReadResult { + /** Content encoded as a string */ + data: string; + /** How `data` is encoded */ + encoding: ContentEncoding; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; +} + +// ─── resourceWrite ─────────────────────────────────────────────────────────── + +/** + * Writes content to a file on the server's filesystem. + * + * Binary content (images, etc.) MUST use `base64` encoding. Text content MAY + * use `utf-8` encoding. + * + * If the file does not exist, it is created. If the file already exists, it is + * overwritten unless `createOnly` is set. + * + * @category Commands + * @method resourceWrite + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the parent directory does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to write to the path. + * @throws `AlreadyExists` (`-32010`) if `createOnly` is set and the file already exists. + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 11, "method": "resourceWrite", + * "params": { "uri": "file:///workspace/hello.txt", "data": "SGVsbG8=", + * "encoding": "base64", "contentType": "text/plain" } } + * + * // Server → Client + * { "jsonrpc": "2.0", "id": 11, "result": {} } + * ``` + */ +export interface IResourceWriteParams { + /** Target file URI on the server filesystem */ + uri: URI; + /** Content encoded as a string */ + data: string; + /** How `data` is encoded */ + encoding: ContentEncoding; + /** Content type (e.g. `"text/plain"`, `"image/png"`) */ + contentType?: string; + /** + * If `true`, the server MUST fail if the file already exists instead of + * overwriting it. Useful for safe creation of new files. + */ + createOnly?: boolean; +} + +/** + * Result of the `resourceWrite` command. + * + * An empty object on success. + */ +export interface IResourceWriteResult { +} + +// ─── resourceList ──────────────────────────────────────────────────────── + +/** + * 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 resourceList + * @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 IResourceListParams { + /** Directory URI on the server filesystem */ + uri: URI; +} + +/** + * Directory entry returned by `resourceList`. + */ +export interface IDirectoryEntry { + /** Base name of the entry */ + name: string; + /** Whether the entry is a file or directory */ + type: 'file' | 'directory'; +} + +/** + * Result of the `resourceList` command. + */ +export interface IResourceListResult { + /** 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; +} + +// ─── resourceCopy ──────────────────────────────────────────────────────────── + +/** + * Copies a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. + * + * @category Commands + * @method resourceCopy + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the source or write to the destination. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. + */ +export interface IResourceCopyParams { + /** Source URI to copy from */ + source: URI; + /** Destination URI to copy to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; +} + +/** + * Result of the `resourceCopy` command. + * + * An empty object on success. + */ +export interface IResourceCopyResult { +} + +// ─── resourceDelete ────────────────────────────────────────────────────────── + +/** + * Deletes a resource at a URI on the server's filesystem. + * + * @category Commands + * @method resourceDelete + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the resource does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to delete the resource. + */ +export interface IResourceDeleteParams { + /** URI of the resource to delete */ + uri: URI; + /** + * If `true` and the target is a directory, delete it and all its contents + * recursively. If `false` (default), deleting a non-empty directory MUST fail. + */ + recursive?: boolean; +} + +/** + * Result of the `resourceDelete` command. + * + * An empty object on success. + */ +export interface IResourceDeleteResult { +} + +// ─── resourceMove ──────────────────────────────────────────────────────────── + +/** + * Moves (renames) a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. + * + * @category Commands + * @method resourceMove + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to move the resource. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. + */ +export interface IResourceMoveParams { + /** Source URI to move from */ + source: URI; + /** Destination URI to move to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; +} + +/** + * Result of the `resourceMove` command. + * + * An empty object on success. + */ +export interface IResourceMoveResult { +} + +// ─── 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..bcf0e7947de3b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/errors.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. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +// ─── 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, + /** + * The target resource already exists and the operation does not allow + * overwriting (e.g. `resourceWrite` with `createOnly: true`). + */ + AlreadyExists: -32010, +} 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..253893ab6619a --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/messages.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. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, 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 }; + 'resourceRead': { params: IResourceReadParams; result: IResourceReadResult }; + 'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult }; + 'resourceList': { params: IResourceListParams; result: IResourceListResult }; + 'resourceCopy': { params: IResourceCopyParams; result: IResourceCopyResult }; + 'resourceDelete': { params: IResourceDeleteParams; result: IResourceDeleteResult }; + 'resourceMove': { params: IResourceMoveParams; result: IResourceMoveResult }; + '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..b4d7e5052c8a7 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +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..128074285b2e7 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -0,0 +1,584 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +import { ActionType } from './actions.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } 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: ${JSON.stringify(value)}`; + (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: { + let next: ISessionState = { + ...state, + summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() }, + activeTurn: { + id: action.turnId, + userMessage: action.userMessage, + responseParts: [], + usage: undefined, + }, + }; + + // If this turn was auto-started from a pending message, remove it + if (action.queuedMessageId) { + if (next.steeringMessage?.id === action.queuedMessageId) { + next = { ...next, steeringMessage: undefined }; + } + if (next.queuedMessages) { + const filtered = next.queuedMessages.filter(m => m.id !== action.queuedMessageId); + next = { ...next, queuedMessages: filtered.length > 0 ? filtered : undefined }; + } + } + + return next; + } + + 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 }, + }; + + // ── Customizations ────────────────────────────────────────────────── + + case ActionType.SessionCustomizationsChanged: + return { ...state, customizations: action.customizations }; + + case ActionType.SessionCustomizationToggled: { + const list = state.customizations; + if (!list) { + return state; + } + const idx = list.findIndex(c => c.customization.uri === action.uri); + if (idx < 0) { + return state; + } + const updated = [...list]; + updated[idx] = { ...list[idx], enabled: action.enabled }; + return { ...state, customizations: updated }; + } + + // ── Truncation ──────────────────────────────────────────────────────── + + case ActionType.SessionTruncated: { + let turns: typeof state.turns; + if (action.turnId === undefined) { + turns = []; + } else { + const idx = state.turns.findIndex(t => t.id === action.turnId); + if (idx < 0) { + return state; + } + turns = state.turns.slice(0, idx + 1); + } + return { + ...state, + turns, + activeTurn: undefined, + summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() }, + }; + } + + // ── Pending Messages ────────────────────────────────────────────────── + + case ActionType.SessionPendingMessageSet: { + const entry: IPendingMessage = { id: action.id, userMessage: action.userMessage }; + if (action.kind === PendingMessageKind.Steering) { + return { ...state, steeringMessage: entry }; + } + const existing = state.queuedMessages ?? []; + const idx = existing.findIndex(m => m.id === action.id); + if (idx >= 0) { + const updated = [...existing]; + updated[idx] = entry; + return { ...state, queuedMessages: updated }; + } + return { ...state, queuedMessages: [...existing, entry] }; + } + + case ActionType.SessionPendingMessageRemoved: { + if (action.kind === PendingMessageKind.Steering) { + if (!state.steeringMessage || state.steeringMessage.id !== action.id) { + return state; + } + return { ...state, steeringMessage: undefined }; + } + const existing = state.queuedMessages; + if (!existing) { + return state; + } + const filtered = existing.filter(m => m.id !== action.id); + return filtered.length === existing.length + ? state + : { ...state, queuedMessages: filtered.length > 0 ? filtered : undefined }; + } + + case ActionType.SessionQueuedMessagesReordered: { + const existing = state.queuedMessages; + if (!existing) { + return state; + } + const byId = new Map(existing.map(m => [m.id, m])); + const ordered = new Set(); + const reordered = action.order + .filter(id => { + if (byId.has(id) && !ordered.has(id)) { + ordered.add(id); + return true; + } + return false; + }) + .map(id => byId.get(id)!); + // Append any messages not mentioned in order, preserving original order + for (const m of existing) { + if (!ordered.has(m.id)) { + reordered.push(m); + } + } + return { ...state, queuedMessages: reordered }; + } + + 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..49cefab87d7af --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -0,0 +1,994 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + +// ─── 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 }; + +// ─── Icon ──────────────────────────────────────────────────────────────────── + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD take steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + */ + src: URI; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + contentType?: string; + + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `"light"` indicates + * the icon is designed to be used with a light background, and `"dark"` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: 'light' | 'dark'; +} + +// ─── 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[]; + /** + * Customizations (Open Plugins) associated with this agent. + * + * Each entry is a reference to an [Open Plugins](https://open-plugins.com/) + * plugin that the agent host can activate for sessions using this agent. + */ + customizations?: ICustomizationRef[]; +} + +/** + * @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; +} + +// ─── Pending Message Types ─────────────────────────────────────────────────── + +/** + * Discriminant for pending message kinds. + * + * @category Pending Message Types + */ +export const enum PendingMessageKind { + /** Injected into the current turn at a convenient point */ + Steering = 'steering', + /** Sent automatically as a new turn after the current turn finishes */ + Queued = 'queued', +} + +/** + * A message queued for future delivery to the agent. + * + * Steering messages are injected into the current turn mid-flight. + * Queued messages are automatically started as new turns after the + * current turn naturally finishes. + * + * @category Pending Message Types + */ +export interface IPendingMessage { + /** Unique identifier for this pending message */ + id: string; + /** The message content */ + userMessage: IUserMessage; +} + +// ─── 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; + /** Message to inject into the current turn at a convenient point */ + steeringMessage?: IPendingMessage; + /** Messages to send automatically as new turns after the current turn finishes */ + queuedMessages?: IPendingMessage[]; + /** + * Server-provided customizations active in this session. + * + * Client-provided customizations are available on + * {@link ISessionActiveClient.customizations | activeClient.customizations}. + */ + customizations?: ISessionCustomization[]; +} + +/** + * 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[]; + /** Customizations this client contributes to the session */ + customizations?: ICustomizationRef[]; +} + +/** + * @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. + */ +export interface IContentRef { + /** Content URI */ + uri: URI; + /** Approximate size in bytes */ + sizeHint?: number; + /** Content MIME type */ + contentType?: string; +} + +/** + * A content part that's a reference to large content stored outside the state tree. + * + * @category Response Parts + */ +export interface IResourceReponsePart extends IContentRef { + /** Discriminant */ + kind: ResponsePartKind.ContentRef; +} + +/** + * 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 | IResourceReponsePart | 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', + EmbeddedResource = 'embeddedResource', + Resource = 'resource', + 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 embedded in a tool result. + * + * Mirrors MCP `EmbeddedResource` for inline binary data. + * + * @category Tool Result Content + */ +export interface IToolResultEmbeddedResourceContent { + type: ToolResultContentType.EmbeddedResource; + /** Base64-encoded data */ + data: string; + /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ + contentType: string; +} + +/** + * A reference to a resource stored outside the tool result. + * + * Wraps {@link IContentRef} for lazy-loading large results. + * + * @category Tool Result Content + */ +export interface IToolResultResourceContent extends IContentRef { + type: ToolResultContentType.Resource; +} + +/** + * Describes a file modification performed by a tool. + * + * Supports creates (only `after`), deletes (only `before`), renames/moves + * (different `uri` in `before` and `after`), and edits (same `uri`, different content). + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** The file state before the edit. Absent for file creations or for in-place file edits. */ + before?: { + /** URI of the file before the edit */ + uri: URI; + /** Reference to the file content before the edit */ + content: IContentRef; + }; + /** The file state after the edit. Absent for file deletions. */ + after?: { + /** URI of the file after the edit */ + uri: URI; + /** Reference to the file content after the edit */ + content: IContentRef; + }; + /** 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 + * `IToolResultResourceContent` for lazy-loading large results and + * `IToolResultFileEditContent` for file edit diffs (AHP extensions). + * + * @category Tool Result Content + */ +export type IToolResultContent = + | IToolResultTextContent + | IToolResultEmbeddedResourceContent + | IToolResultResourceContent + | IToolResultFileEditContent; + +// ─── Customization Types ───────────────────────────────────────────────────── + +/** + * A reference to an [Open Plugins](https://open-plugins.com/) plugin. + * + * This is intentionally thin — AHP specifies plugin identity and metadata + * but not implementation details, which are defined by the Open Plugins spec. + * + * @category Customization Types + */ +export interface ICustomizationRef { + /** Plugin URI (e.g. an HTTPS URL or marketplace identifier) */ + uri: URI; + /** Human-readable name */ + displayName: string; + /** Description of what the plugin provides */ + description?: string; + /** Icons for the plugin */ + icons?: Icon[]; + /** + * Opaque version token for this customization. + * + * Clients SHOULD include a nonce with every customization they provide. + * Consumers can compare nonces to detect whether a customization has + * changed since it was last seen, avoiding redundant reloads or copies. + */ + nonce?: string; +} + +/** + * Loading status for a server-managed customization. + * + * @category Customization Types + */ +export const enum CustomizationStatus { + /** Plugin is being loaded */ + Loading = 'loading', + /** Plugin is fully operational */ + Loaded = 'loaded', + /** Plugin partially loaded but has warnings */ + Degraded = 'degraded', + /** Plugin was unable to load */ + Error = 'error', +} + +/** + * A customization active in a session. + * + * Entries without a `clientId` are server-provided; entries with a `clientId` + * originate from that client. + * + * @category Customization Types + */ +export interface ISessionCustomization { + /** The plugin this customization refers to */ + customization: ICustomizationRef; + /** Whether this customization is currently enabled */ + enabled: boolean; + /** Server-reported loading status */ + status?: CustomizationStatus; + /** + * Human-readable status detail (e.g. error message or degradation warning). + */ + statusMessage?: string; +} + +// ─── 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..11d2bb4b017dc --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.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 + +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, + [ActionType.SessionPendingMessageSet]: 1, + [ActionType.SessionPendingMessageRemoved]: 1, + [ActionType.SessionQueuedMessagesReordered]: 1, + [ActionType.SessionCustomizationsChanged]: 1, + [ActionType.SessionCustomizationToggled]: 1, + [ActionType.SessionTruncated]: 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..130e15a2c3470 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionActions.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. + *--------------------------------------------------------------------------------------------*/ + +// 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 ISessionCustomizationsChangedAction, + type ISessionCustomizationToggledAction, + type ISessionPendingMessageSetAction, + type ISessionPendingMessageRemovedAction, + type ISessionQueuedMessagesReorderedAction, + 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, + ISessionToolCallApprovedAction, + ISessionToolCallCompleteAction, + ISessionToolCallConfirmedAction, + ISessionToolCallDeniedAction, + ISessionToolCallDeltaAction, + ISessionToolCallReadyAction, + ISessionToolCallResultConfirmedAction, + ISessionToolCallStartAction, + ISessionTitleChangedAction, + ISessionTurnCancelledAction, + ISessionTurnCompleteAction, + ISessionTurnStartedAction, + ISessionUsageAction, + IStateAction, + ISessionPendingMessageSetAction, + ISessionPendingMessageRemovedAction, + ISessionQueuedMessagesReorderedAction, +} 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 = ISessionToolCallApprovedAction; +export type IToolCallDeniedAction = 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; +export type ICustomizationsChangedAction = import('./protocol/actions.js').ISessionCustomizationsChangedAction; +export type ICustomizationToggledAction = import('./protocol/actions.js').ISessionCustomizationToggledAction; + +export type IPendingMessageSetAction = ISessionPendingMessageSetAction; +export type IPendingMessageRemovedAction = ISessionPendingMessageRemovedAction; +export type IQueuedMessagesReorderedAction = ISessionQueuedMessagesReorderedAction; + +// 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..dad2824e77d82 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionCapabilities.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 const PROTOCOL_VERSION = 1; +export const MIN_PROTOCOL_VERSION = 1; + +/** + * 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..722382508e48b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionClientState.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. + *--------------------------------------------------------------------------------------------*/ + +// 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 readonly _seqAllocator: () => number; + 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, seqAllocator: () => number) { + super(); + this._clientId = clientId; + this._log = msg => logService.warn(`[SessionClientState] ${msg}`); + this._seqAllocator = seqAllocator; + } + + 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._seqAllocator(); + 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..b093358ca6ae9 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + ICreateSessionParams, + IDirectoryEntry, + IDispatchActionParams, + IDisposeSessionParams, + IFetchTurnsParams, + IFetchTurnsResult, + IInitializeParams, + IInitializeResult, + IListSessionsParams, + IListSessionsResult, + IReconnectParams, + IReconnectReplayResult, + IReconnectResult, + IReconnectSnapshotResult, + IResourceCopyParams, + IResourceCopyResult, + IResourceDeleteParams, + IResourceDeleteResult, + IResourceListParams, + IResourceListResult, + IResourceMoveParams, + IResourceMoveResult, + IResourceReadParams, + IResourceReadResult, + IResourceWriteParams, + IResourceWriteResult, + 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..6b9ddb22fcaf0 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * 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 ICustomizationRef, + type ISessionCustomization, + type IToolResultEmbeddedResourceContent as IToolResultBinaryContent, + type IToolResultContent, + type IToolResultFileEditContent, + type IToolResultTextContent, + type ITurn, + type IUsageInfo, + type IUserMessage, + type IPendingMessage, + type StringOrMarkdown, + type URI, + AttachmentType, + CustomizationStatus, + PendingMessageKind, + PolicyState, + ResponsePartKind, + SessionLifecycle, + SessionStatus, + ToolCallConfirmationReason, + ToolCallCancellationReason, + ToolCallStatus, + ToolResultContentType, + TurnState, +} from './protocol/state.js'; + +// ---- File edit kind --------------------------------------------------------- + +/** + * The kind of file edit operation. Derived from the presence/absence of + * `before`/`after` in {@link IToolResultFileEditContent}. + */ +export const enum FileEditKind { + /** Content edit (same file URI, different content). */ + Edit = 'edit', + /** File creation (no before state). */ + Create = 'create', + /** File deletion (no after state). */ + Delete = 'delete', + /** File rename/move (different before and after URIs). */ + Rename = 'rename', +} + +// ---- 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..823f06cb1b842 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/sessionTransport.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. + *--------------------------------------------------------------------------------------------*/ + +// 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, IJsonRpcRequest } 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 | IJsonRpcRequest): void; +} + +/** + * A client-side transport that requires an explicit connection step + * before messages can be exchanged. + */ +export interface IClientTransport extends IProtocolTransport { + /** Establish the underlying connection (e.g. open a WebSocket). */ + connect(): Promise; +} + +/** Type guard for transports that require an explicit connection step. */ +export function isClientTransport(transport: IProtocolTransport): transport is IClientTransport { + return typeof (transport as IClientTransport).connect === 'function'; +} + +/** + * 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/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..d75cd8c6062dd --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.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 { 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 { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, 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); + } + private _nextSeq = 1; + nextClientSeq(): number { + return this._nextSeq++; + } + resourceList(uri: URI): Promise { + return this._proxy.resourceList(uri); + } + resourceRead(uri: URI): Promise { + return this._proxy.resourceRead(uri); + } + resourceWrite(params: IResourceWriteParams): Promise { + return this._proxy.resourceWrite(params); + } + resourceCopy(params: IResourceCopyParams): Promise { + return this._proxy.resourceCopy(params); + } + resourceDelete(params: IResourceDeleteParams): Promise { + return this._proxy.resourceDelete(params); + } + resourceMove(params: IResourceMoveParams): Promise { + return this._proxy.resourceMove(params); + } + 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..cef610a4d23f4 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -0,0 +1,376 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.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, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; +import { AhpErrorCodes } from '../common/state/protocol/errors.js'; +import { ContentEncoding } from '../common/state/protocol/commands.js'; +import type { ISessionSummary } from '../common/state/sessionState.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.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 _address: string; + private readonly _transport: IProtocolTransport; + private readonly _connectionAuthority: string; + 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._address; + } + + get defaultDirectory(): string | undefined { + return this._defaultDirectory; + } + + constructor( + address: string, + transport: IProtocolTransport, + @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + this._address = address; + this._connectionAuthority = agentHostAuthority(address); + this._transport = transport; + this._register(this._transport); + 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 { + if (isClientTransport(this._transport)) { + 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 ? fromAgentHostUri(config.workingDirectory).toString() : undefined, + }); + 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' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, + })); + } + + /** + * List the contents of a directory on the remote host's filesystem. + */ + async resourceList(uri: URI): Promise { + return await this._sendRequest('resourceList', { uri: uri.toString() }); + } + + /** + * Read the content of a resource on the remote host. + */ + async resourceRead(uri: URI): Promise { + return this._sendRequest('resourceRead', { uri: uri.toString() }); + } + + async resourceWrite(params: ICommandMap['resourceWrite']['params']): Promise { + return this._sendRequest('resourceWrite', params); + } + + async resourceCopy(params: ICommandMap['resourceCopy']['params']): Promise { + return this._sendRequest('resourceCopy', params); + } + + async resourceDelete(params: ICommandMap['resourceDelete']['params']): Promise { + return this._sendRequest('resourceDelete', params); + } + + async resourceMove(params: ICommandMap['resourceMove']['params']): Promise { + return this._sendRequest('resourceMove', params); + } + + private _handleMessage(msg: IProtocolMessage): void { + if (isJsonRpcRequest(msg)) { + this._handleReverseRequest(msg.id, msg.method, msg.params); + } else 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)); + } + } + + /** + * Handles reverse RPC requests from the server (e.g. resourceList, + * resourceRead). Reads from the local file service and sends a response. + */ + private _handleReverseRequest(id: number, method: string, params: unknown): void { + const sendResult = (result: unknown) => { + this._transport.send({ jsonrpc: '2.0', id, result }); + }; + const sendError = (err: unknown) => { + const fsCode = toFileSystemProviderErrorCode(err instanceof Error ? err : undefined); + let code = -32000; + switch (fsCode) { + case FileSystemProviderErrorCode.FileNotFound: code = AhpErrorCodes.NotFound; break; + case FileSystemProviderErrorCode.NoPermissions: code = AhpErrorCodes.PermissionDenied; break; + case FileSystemProviderErrorCode.FileExists: code = AhpErrorCodes.AlreadyExists; break; + } + this._transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); + }; + const handle = (fn: () => Promise) => { + fn().then(sendResult, sendError); + }; + + const p = params as Record; + switch (method) { + case 'resourceList': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const stat = await this._fileService.resolve(URI.parse(p.uri as string)); + return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) }; + }); + case 'resourceRead': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const content = await this._fileService.readFile(URI.parse(p.uri as string)); + return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 }; + }); + case 'resourceWrite': + if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; } + return handle(async () => { + const writeUri = URI.parse(p.uri as string); + const buf = p.encoding === ContentEncoding.Base64 + ? decodeBase64(p.data as string) + : VSBuffer.fromString(p.data as string); + if (p.createOnly) { + await this._fileService.createFile(writeUri, buf, { overwrite: false }); + } else { + await this._fileService.writeFile(writeUri, buf); + } + return {}; + }); + case 'resourceDelete': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(() => this._fileService.del(URI.parse(p.uri as string), { recursive: !!p.recursive }).then(() => ({}))); + case 'resourceMove': + if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; } + return handle(() => this._fileService.move(URI.parse(p.source as string), URI.parse(p.destination as string), !p.failIfExists).then(() => ({}))); + default: + this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); + sendError(new Error(`Unknown method: ${method}`)); + } + } + + /** 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..20bf4ff37cc92 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.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. + *--------------------------------------------------------------------------------------------*/ + +// 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, + RemoteAgentHostConnectionStatus, + RemoteAgentHostsEnabledSettingId, + RemoteAgentHostsSettingId, + type IRemoteAgentHostConnectionInfo, + type IRemoteAgentHostEntry, +} from '../common/remoteAgentHostService.js'; +import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; +import { WebSocketClientTransport } from './webSocketClientTransport.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; + /** Current connection status for UI display. */ + status: RemoteAgentHostConnectionStatus; +} + +export class RemoteAgentHostService extends Disposable implements IRemoteAgentHostService { + private static readonly ConnectionWaitTimeout = 10000; + /** Initial reconnect delay in milliseconds. */ + private static readonly ReconnectInitialDelay = 1000; + /** Maximum reconnect delay in milliseconds. */ + private static readonly ReconnectMaxDelay = 30000; + + 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 _tokens = new Map(); + private readonly _pendingConnectionWaits = new Map>(); + /** Pending reconnect timeouts, keyed by normalized address. */ + private readonly _reconnectTimeouts = new Map>(); + /** Current reconnect attempt count per address for exponential backoff. */ + private readonly _reconnectAttempts = 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) { + result.push({ + address, + name: this._names.get(address) ?? address, + clientId: entry.client.clientId, + defaultDirectory: entry.client.defaultDirectory, + status: entry.status, + }); + } + 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; + } + + reconnect(address: string): void { + const normalized = normalizeRemoteAgentHostAddress(address); + + // SSH entries are reconnected by the SSH service, not via WebSocket + const configuredEntry = this._getConfiguredEntries().find( + e => normalizeRemoteAgentHostAddress(e.address) === normalized + ); + if (configuredEntry?.sshConfigHost) { + return; + } + + const token = this._tokens.get(normalized); + + // Cancel any pending reconnect + this._cancelReconnect(normalized); + this._reconnectAttempts.delete(normalized); + + // Tear down existing connection if present + const entry = this._entries.get(normalized); + if (entry) { + this._entries.delete(normalized); + entry.store.dispose(); + } + + // Start fresh connection attempt + this._connectTo(normalized, token); + } + + 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 addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise { + const address = entry.address; + + // Dispose any existing entry for this address to avoid leaking + // old protocol clients and relay transports on reconnect. + const existingEntry = this._entries.get(address); + if (existingEntry) { + this._entries.delete(address); + existingEntry.store.dispose(); + } + + const store = new DisposableStore(); + + // Create a connection entry wrapping the pre-connected client + const protocolClient = connection as RemoteAgentHostProtocolClient; + store.add(protocolClient); + const connEntry: IConnectionEntry = { store, client: protocolClient, connected: true, status: RemoteAgentHostConnectionStatus.Connected }; + this._entries.set(address, connEntry); + this._names.set(address, entry.name); + if (entry.connectionToken) { + this._tokens.set(address, entry.connectionToken); + } + + store.add(protocolClient.onDidClose(() => { + if (this._entries.get(address) === connEntry) { + connEntry.connected = false; + connEntry.status = RemoteAgentHostConnectionStatus.Disconnected; + this._onDidChangeConnections.fire(); + } + })); + + // Persist SSH entries — await so that the config is written before + // onDidChangeConnections fires, ensuring _reconcile creates the provider. + await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); + + this._onDidChangeConnections.fire(); + + return { + address, + name: entry.name, + clientId: protocolClient.clientId, + defaultDirectory: protocolClient.defaultDirectory, + status: RemoteAgentHostConnectionStatus.Connected, + }; + } + + 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._tokens.delete(normalized); + this._cancelReconnect(normalized); + this._reconnectAttempts.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._cancelReconnect(address); + this._removeConnection(address); + } + this._names.clear(); + this._tokens.clear(); + this._reconnectAttempts.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(); + this._tokens.clear(); + for (const entry of entries) { + this._names.set(entry.address, entry.name); + this._tokens.set(entry.address, entry.connectionToken); + 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._cancelReconnect(address); + this._reconnectAttempts.delete(address); + this._removeConnection(address); + } + } + + // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService) + for (const entry of entries) { + if (!this._entries.has(entry.address) && !entry.sshConfigHost) { + 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 { + // Dispose any existing entry for this address before creating a new one + // to avoid leaking disposables on reconnect. + const existingEntry = this._entries.get(address); + if (existingEntry) { + this._entries.delete(address); + existingEntry.store.dispose(); + } + + const store = new DisposableStore(); + const transport = store.add(new WebSocketClientTransport(address, connectionToken)); + const client = store.add(this._instantiationService.createInstance(RemoteAgentHostProtocolClient, address, transport)); + const entry: IConnectionEntry = { store, client, connected: false, status: RemoteAgentHostConnectionStatus.Connecting }; + this._entries.set(address, entry); + + // Guard against stale callbacks: only act if the + // current entry for this address is still the one we created. + const isCurrentEntry = () => this._entries.get(address) === entry; + + store.add(client.onDidClose(() => { + if (!isCurrentEntry()) { + return; + } + this._logService.warn(`[RemoteAgentHost] Connection closed: ${address}`); + entry.connected = false; + entry.status = RemoteAgentHostConnectionStatus.Disconnected; + this._onDidChangeConnections.fire(); + // Schedule reconnect if the address is still configured + this._scheduleReconnect(address, connectionToken); + })); + + this._logService.info(`[RemoteAgentHost] Connecting to ${address}`); + this._onDidChangeConnections.fire(); + client.connect().then(() => { + if (store.isDisposed) { + return; // removed before connect resolved + } + this._logService.info(`[RemoteAgentHost] Connected to ${address}`); + entry.connected = true; + entry.status = RemoteAgentHostConnectionStatus.Connected; + this._reconnectAttempts.delete(address); + this._resolvePendingConnectionWait(address); + this._onDidChangeConnections.fire(); + }).catch(err => { + if (!isCurrentEntry()) { + return; + } + this._logService.error(`[RemoteAgentHost] Failed to connect to ${address}. Verify address and connectionToken`, err); + entry.status = RemoteAgentHostConnectionStatus.Disconnected; + // Clean up the failed entry + this._entries.delete(address); + entry.store.dispose(); + this._rejectPendingConnectionWait(address, err); + this._onDidChangeConnections.fire(); + // Schedule reconnect if the address is still configured + this._scheduleReconnect(address, connectionToken); + }); + } + + /** + * Schedule a reconnect attempt with exponential backoff. + * Only reconnects if the address is still in the configured entries. + */ + private _scheduleReconnect(address: string, connectionToken?: string): void { + // Don't reconnect if the address was removed from settings + if (!this._isAddressConfigured(address)) { + this._logService.info(`[RemoteAgentHost] Not reconnecting to ${address}: no longer configured`); + return; + } + + const attempt = (this._reconnectAttempts.get(address) ?? 0) + 1; + this._reconnectAttempts.set(address, attempt); + const delay = Math.min( + RemoteAgentHostService.ReconnectInitialDelay * Math.pow(2, attempt - 1), + RemoteAgentHostService.ReconnectMaxDelay, + ); + + this._logService.info(`[RemoteAgentHost] Scheduling reconnect to ${address} in ${delay}ms (attempt ${attempt})`); + + this._cancelReconnect(address); + const timeout = setTimeout(() => { + this._reconnectTimeouts.delete(address); + if (this._isAddressConfigured(address)) { + this._connectTo(address, connectionToken ?? this._tokens.get(address)); + } + }, delay); + this._reconnectTimeouts.set(address, timeout); + } + + /** Cancel a pending reconnect timeout for the given address. */ + private _cancelReconnect(address: string): void { + const timeout = this._reconnectTimeouts.get(address); + if (timeout !== undefined) { + clearTimeout(timeout); + this._reconnectTimeouts.delete(address); + } + } + + /** Check whether the given normalized address is still in the configured entries. */ + private _isAddressConfigured(address: string): boolean { + const entries = this._getConfiguredEntries(); + return entries.some(e => normalizeRemoteAgentHostAddress(e.address) === address); + } + + private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { + return this.connections.find(connection => connection.address === address && connection.status === RemoteAgentHostConnectionStatus.Connected); + } + + 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 timeout of this._reconnectTimeouts.values()) { + clearTimeout(timeout); + } + this._reconnectTimeouts.clear(); + this._reconnectAttempts.clear(); + 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/sshRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts new file mode 100644 index 0000000000000..88eea907e56b8 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRelayTransport.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../base/common/lifecycle.js'; +import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IProtocolTransport } from '../common/state/sessionTransport.js'; +import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService } from '../common/sshRemoteAgentHost.js'; + +/** + * A protocol transport that relays messages through the shared process + * SSH tunnel via IPC, instead of using a direct WebSocket connection. + * + * The shared process manages the actual WebSocket-over-SSH connection + * and forwards messages bidirectionally through this IPC channel. + */ +export class SSHRelayTransport 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 _connectionId: string, + private readonly _sshService: ISSHRemoteAgentHostMainService, + ) { + super(); + + // Listen for relay messages from the shared process + this._register(this._sshService.onDidRelayMessage((msg: ISSHRelayMessage) => { + if (msg.connectionId === this._connectionId) { + try { + const parsed = JSON.parse(msg.data) as IProtocolMessage; + this._onMessage.fire(parsed); + } catch { + // Malformed message — drop + } + } + })); + + // Listen for relay close + this._register(this._sshService.onDidRelayClose((closedId: string) => { + if (closedId === this._connectionId) { + this._onClose.fire(); + } + })); + } + + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + this._sshService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { + // Send failed — connection probably closed + }); + } +} diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.ts new file mode 100644 index 0000000000000..9e1930a98e850 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostService.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 { ISSHRemoteAgentHostService } from '../common/sshRemoteAgentHost.js'; +import { SSHRemoteAgentHostService } from './sshRemoteAgentHostServiceImpl.js'; + +registerSingleton(ISSHRemoteAgentHostService, SSHRemoteAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts new file mode 100644 index 0000000000000..21595d6adb0de --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * 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, toDisposable } from '../../../base/common/lifecycle.js'; +import { ILogService } from '../../log/common/log.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { SSHRelayTransport } from './sshRelayTransport.js'; +import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; +import { + ISSHRemoteAgentHostService, + SSH_REMOTE_AGENT_HOST_CHANNEL, + type ISSHAgentHostConfig, + type ISSHAgentHostConnection, + type ISSHRemoteAgentHostMainService, + type ISSHResolvedConfig, + type ISSHConnectProgress, +} from '../common/sshRemoteAgentHost.js'; + +/** + * Renderer-side implementation of {@link ISSHRemoteAgentHostService} that + * delegates the actual SSH work to the main process via IPC, then registers + * the resulting connection with the renderer-local {@link IRemoteAgentHostService}. + */ +export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteAgentHostService { + declare readonly _serviceBrand: undefined; + + private readonly _mainService: ISSHRemoteAgentHostMainService; + + private readonly _onDidChangeConnections = this._register(new Emitter()); + readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + + readonly onDidReportConnectProgress: Event; + + private readonly _connections = new Map(); + + constructor( + @ISharedProcessService sharedProcessService: ISharedProcessService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + this._mainService = ProxyChannel.toService( + sharedProcessService.getChannel(SSH_REMOTE_AGENT_HOST_CHANNEL), + ); + + this.onDidReportConnectProgress = this._mainService.onDidReportConnectProgress; + + // When shared process fires onDidCloseConnection, clean up the renderer-side handle. + // Do NOT remove the configured entry — it stays in settings so startup reconnect + // can re-establish the SSH tunnel on next launch. + this._register(this._mainService.onDidCloseConnection(connectionId => { + const handle = this._connections.get(connectionId); + if (handle) { + this._connections.delete(connectionId); + handle.fireClose(); + handle.dispose(); + this._onDidChangeConnections.fire(); + } + })); + } + + get connections(): readonly ISSHAgentHostConnection[] { + return [...this._connections.values()]; + } + + async connect(config: ISSHAgentHostConfig): Promise { + this._logService.info('[SSHRemoteAgentHost] Connecting to ' + config.host); + const augmentedConfig = this._augmentConfig(config); + const result = await this._mainService.connect(augmentedConfig); + this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId); + + const existing = this._connections.get(result.connectionId); + if (existing) { + this._logService.trace('[SSHRemoteAgentHost] Returning existing connection handle'); + return existing; + } + + // Create relay transport + protocol client, then register with RemoteAgentHostService + try { + const protocolClient = this._createRelayClient(result); + await protocolClient.connect(); + this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); + + await this._remoteAgentHostService.addSSHConnection({ + address: result.address, + name: result.name, + connectionToken: result.connectionToken, + sshConfigHost: result.sshConfigHost, + }, protocolClient); + } catch (err) { + this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + + const handle = new SSHAgentHostConnectionHandle( + result.config, + result.address, + result.name, + () => this._mainService.disconnect(result.connectionId), + ); + + this._connections.set(result.connectionId, handle); + this._onDidChangeConnections.fire(); + + return handle; + } + + async disconnect(host: string): Promise { + await this._mainService.disconnect(host); + } + + async listSSHConfigHosts(): Promise { + return this._mainService.listSSHConfigHosts(); + } + + async resolveSSHConfig(host: string): Promise { + return this._mainService.resolveSSHConfig(host); + } + + async reconnect(sshConfigHost: string, name: string): Promise { + const commandOverride = this._getRemoteAgentHostCommand(); + const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride); + + const existing = this._connections.get(result.connectionId); + if (existing) { + return existing; + } + + const protocolClient = this._createRelayClient(result); + await protocolClient.connect(); + + await this._remoteAgentHostService.addSSHConnection({ + address: result.address, + name: result.name, + connectionToken: result.connectionToken, + sshConfigHost: result.sshConfigHost, + }, protocolClient); + + const handle = new SSHAgentHostConnectionHandle( + result.config, + result.address, + result.name, + () => this._mainService.disconnect(result.connectionId), + ); + + this._connections.set(result.connectionId, handle); + this._onDidChangeConnections.fire(); + + return handle; + } + + private _createRelayClient(result: { connectionId: string; address: string }): RemoteAgentHostProtocolClient { + const transport = new SSHRelayTransport(result.connectionId, this._mainService); + return this._instantiationService.createInstance( + RemoteAgentHostProtocolClient, result.address, transport, + ); + } + + private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig { + const commandOverride = this._getRemoteAgentHostCommand(); + if (commandOverride) { + return { ...config, remoteAgentHostCommand: commandOverride }; + } + return config; + } + + private _getRemoteAgentHostCommand(): string | undefined { + return this._configurationService.getValue('chat.sshRemoteAgentHostCommand') || undefined; + } +} + +/** + * Lightweight renderer-side handle that represents a connection + * managed by the main process. + */ +class SSHAgentHostConnectionHandle extends Disposable implements ISSHAgentHostConnection { + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + private _closedByMain = false; + + constructor( + readonly config: ISSHAgentHostConnection['config'], + readonly localAddress: string, + readonly name: string, + disconnectFn: () => Promise, + ) { + super(); + + // When this handle is disposed, tear down the main-process tunnel + // (skip if already closed from the main process side) + this._register(toDisposable(() => { + if (!this._closedByMain) { + disconnectFn().catch(() => { /* best effort */ }); + } + })); + } + + /** Called by the service when the main process signals connection closure. */ + fireClose(): void { + this._closedByMain = true; + this._onDidClose.fire(); + } +} 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..457ef7ad2c5f7 --- /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 { IClientTransport } 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 IClientTransport} with JSON serialization and URI revival. + */ +export class WebSocketClientTransport extends Disposable implements IClientTransport { + + 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..9a06daa0597c1 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * 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, + IAgentMessageEvent, + 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': { + // The SDK fires a `message` event with the complete assembled + // content after all streaming deltas. If delta events already + // captured the text (tracked via _currentMarkdownPartId), skip. + // Otherwise the text arrived without preceding deltas (e.g. + // after tool calls), so emit a new response part. + const e = event as IAgentMessageEvent; + if (e.role !== 'assistant' || !e.content) { + return undefined; + } + const existingPartId = this._currentMarkdownPartId.get(session); + if (existingPartId) { + // Deltas already streamed the content for this part + return undefined; + } + const partId = generateUuid(); + this._currentMarkdownPartId.set(session, partId); + return { + type: ActionType.SessionResponsePart, + session, + turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content: e.content }, + }; + } + + 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..b3e863729b415 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostMain.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 { 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 } from '../common/agentService.js'; +import { AgentService } from './agentService.js'; +import { CopilotAgent } from './copilot/copilotAgent.js'; +import { ProtocolServerHandler } 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 { IFileService } from '../../files/common/files.js'; +import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js'; +import { Schemas } from '../../../base/common/network.js'; +import { InstantiationService } from '../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; +import { SessionDataService } from './sessionDataService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; +import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; +import { IAgentPluginManager } from '../common/agentPluginManager.js'; +import { AgentPluginManager } from './agentPluginManager.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); + const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); + const diServices = new ServiceCollection(); + diServices.set(ILogService, logService); + diServices.set(IFileService, fileService); + diServices.set(ISessionDataService, sessionDataService); + diServices.set(IAgentPluginManager, pluginManager); + const instantiationService = new InstantiationService(diServices); + agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); + } 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, fileService, 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, fileService: IFileService, 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, + )); + + const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); + disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); + + const protocolHandler = disposables.add(new ProtocolServerHandler( + agentService, + agentService.stateManager, + wsServer, + { defaultDirectory: URI.file(os.homedir()).toString() }, + clientFileSystemProvider, + 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..e46265f2e785f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * 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 ] [--host ] [--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 * as os from 'os'; +import { DisposableStore } from '../../../base/common/lifecycle.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 { AgentService } from './agentService.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'; +import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; +import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; +import { resolveServerUrls } from './serverUrls.js'; +import { AgentPluginManager } from './agentPluginManager.js'; +import { IAgentPluginManager } from '../common/agentPluginManager.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 host: string | undefined; + 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 hostIdx = argv.indexOf('--host'); + const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined; + 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, host, 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'); + + // 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 agent service (owns SessionStateManager + AgentSideEffects internally) + const agentService = new AgentService(logService, fileService, sessionDataService); + disposables.add(agentService); + + // Register agents + if (!options.quiet) { + // Production agents (require DI) + const diServices = new ServiceCollection(); + const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); + diServices.set(IProductService, productService); + diServices.set(INativeEnvironmentService, environmentService); + diServices.set(ILogService, logService); + diServices.set(IFileService, fileService); + diServices.set(ISessionDataService, sessionDataService); + diServices.set(IAgentPluginManager, pluginManager); + const instantiationService = new InstantiationService(diServices); + const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); + agentService.registerProvider(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()); + agentService.registerProvider(mockAgent); + }).catch(err => { + logService.error('[AgentHostServer] Failed to load mock agent', err); + }); + } + + // WebSocket server + const wsServer = disposables.add(await WebSocketProtocolServer.create({ + port: options.port, + host: options.host, + connectionTokenValidate: options.connectionToken + ? token => token === options.connectionToken + : undefined, + }, logService)); + + + const clientFileSystemProvider = disposables.add(new AgentHostClientFileSystemProvider()); + disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); + + // Wire up protocol handler + disposables.add(new ProtocolServerHandler( + agentService, + agentService.stateManager, + wsServer, + { defaultDirectory: URI.file(os.homedir()).toString() }, + clientFileSystemProvider, + logService, + )); + + // Report ready + function reportReady(addr: string): void { + const listeningPort = Number(addr.split(':').pop()); + process.stdout.write(`READY:${listeningPort}\n`); + + const urls = resolveServerUrls(options.host, listeningPort); + for (const url of urls.local) { + log(` Local: ${url}`); + logService.info(`[AgentHostServer] Local: ${url}`); + } + for (const url of urls.network) { + log(` Network: ${url}`); + logService.info(`[AgentHostServer] Network: ${url}`); + } + if (urls.network.length === 0 && options.host === undefined) { + log(' Network: use --host to expose'); + logService.info('[AgentHostServer] Network: use --host to expose'); + } + } + + 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/agentPluginManager.ts b/src/vs/platform/agentHost/node/agentPluginManager.ts new file mode 100644 index 0000000000000..2ba70f32a06a4 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentPluginManager.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 { VSBuffer } from '../../../base/common/buffer.js'; +import { SequencerByKey } from '../../../base/common/async.js'; +import { URI } from '../../../base/common/uri.js'; +import { IFileService } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js'; +import { CustomizationStatus, type ICustomizationRef, type ISessionCustomization } from '../common/state/sessionState.js'; +import { toAgentClientUri } from '../common/agentClientUri.js'; + +const DEFAULT_MAX_PLUGINS = 20; + +/** On-disk cache entry format. */ +interface ICacheEntry { + readonly uri: string; + readonly nonce: string; +} + +/** + * Implementation of {@link IAgentPluginManager}. + * + * Syncs plugin directories to local storage under + * `{userDataPath}/agentPlugins/{key}/`. Uses a {@link SequencerByKey} + * per plugin URI so that concurrent syncs of the same plugin are + * serialized and cannot clobber each other. + * + * The nonce cache and LRU order are persisted to a JSON file in the + * base path so they survive process restarts. + */ +export class AgentPluginManager implements IAgentPluginManager { + declare readonly _serviceBrand: undefined; + + private readonly _basePath: URI; + private readonly _cachePath: URI; + private readonly _maxPlugins: number; + + /** Serializes concurrent sync operations per plugin URI. */ + private readonly _sequencer = new SequencerByKey(); + + /** Nonces for plugins on disk, keyed by original customization URI string. */ + private readonly _cachedNonces = new Map(); + + /** LRU order: most recently used original customization URI strings at the end. */ + private readonly _lruOrder: string[] = []; + + /** Whether the on-disk cache has been loaded. */ + private _cacheLoaded = false; + + constructor( + userDataPath: URI, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + maxPlugins: number = DEFAULT_MAX_PLUGINS, + ) { + this._basePath = URI.joinPath(userDataPath, 'agentPlugins'); + this._cachePath = URI.joinPath(this._basePath, 'cache.json'); + this._maxPlugins = maxPlugins; + } + + async syncCustomizations( + clientId: string, + customizations: ICustomizationRef[], + progress?: (status: ISessionCustomization[]) => void, + ): Promise { + await this._ensureCacheLoaded(); + + // Build initial loading status and fire it immediately via progress + const statuses: ISessionCustomization[] = customizations.map(c => ({ + customization: c, + enabled: true, + status: CustomizationStatus.Loading, + })); + progress?.([...statuses]); + + // Sync each customization in parallel, serialized per URI + const results = await Promise.all(customizations.map((ref, i) => + this._sequencer.queue(ref.uri, async (): Promise => { + try { + const pluginDir = await this._syncPlugin(clientId, ref); + statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Loaded }; + progress?.([...statuses]); + return { customization: statuses[i], pluginDir }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`); + statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message }; + progress?.([...statuses]); + return { customization: statuses[i] }; + } + }) + )); + + return results; + } + + // ---- plugin storage logic ----------------------------------------------- + + /** + * Syncs a single plugin to local storage. Skips the copy when the + * nonce matches the cached value. Returns the local directory URI. + */ + private async _syncPlugin(clientId: string, ref: ICustomizationRef): Promise { + const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId); + const key = this._keyForUri(ref.uri); + const destDir = URI.joinPath(this._basePath, key); + + // Nonce cache hit — skip copy + if (ref.nonce && this._cachedNonces.get(ref.uri) === ref.nonce) { + this._touchLru(ref.uri); + this._logService.trace(`[AgentPluginManager] Nonce match for ${ref.uri}, skipping copy`); + return destDir; + } + + this._logService.info(`[AgentPluginManager] Syncing plugin: ${ref.uri} → ${destDir.toString()}`); + + await this._fileService.copy(pluginUri, destDir, true); + + if (ref.nonce) { + this._cachedNonces.set(ref.uri, ref.nonce); + } + this._touchLru(ref.uri); + await this._evictIfNeeded(); + await this._persistCache(); + + return destDir; + } + + private _keyForUri(uri: string): string { + return uri.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 128); + } + + private _touchLru(uri: string): void { + const idx = this._lruOrder.indexOf(uri); + if (idx !== -1) { + this._lruOrder.splice(idx, 1); + } + this._lruOrder.push(uri); + } + + private async _evictIfNeeded(): Promise { + while (this._lruOrder.length > this._maxPlugins) { + const evictUri = this._lruOrder.shift(); + if (!evictUri) { + break; + } + this._cachedNonces.delete(evictUri); + const evictKey = this._keyForUri(evictUri); + const evictDir = URI.joinPath(this._basePath, evictKey); + this._logService.info(`[AgentPluginManager] Evicting plugin: ${evictUri}`); + try { + await this._fileService.del(evictDir, { recursive: true }); + } catch (err) { + this._logService.warn(`[AgentPluginManager] Failed to evict plugin: ${evictUri}`, err); + } + } + } + + // ---- cache persistence -------------------------------------------------- + + private async _ensureCacheLoaded(): Promise { + if (this._cacheLoaded) { + return; + } + this._cacheLoaded = true; + + try { + if (!await this._fileService.exists(this._cachePath)) { + return; + } + const content = await this._fileService.readFile(this._cachePath); + const entries: ICacheEntry[] = JSON.parse(content.value.toString()); + if (!Array.isArray(entries)) { + return; + } + + // Entries are stored in LRU order (oldest first) + for (const entry of entries) { + if (typeof entry.uri === 'string' && typeof entry.nonce === 'string') { + this._cachedNonces.set(entry.uri, entry.nonce); + this._lruOrder.push(entry.uri); + } + } + this._logService.trace(`[AgentPluginManager] Loaded ${entries.length} cache entries from disk`); + } catch (err) { + this._logService.warn('[AgentPluginManager] Failed to load cache from disk', err); + } + } + + private async _persistCache(): Promise { + try { + // Write entries in LRU order (oldest first) + const entries: ICacheEntry[] = []; + for (const uri of this._lruOrder) { + const nonce = this._cachedNonces.get(uri); + if (nonce) { + entries.push({ uri, nonce }); + } + } + await this._fileService.createFolder(this._basePath); + await this._fileService.writeFile(this._cachePath, VSBuffer.fromString(JSON.stringify(entries))); + } catch (err) { + this._logService.warn('[AgentPluginManager] Failed to persist cache to disk', err); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts new file mode 100644 index 0000000000000..6612067ee1799 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -0,0 +1,621 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +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 { generateUuid } from '../../../base/common/uuid.js'; +import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type IResponsePart, type ISessionSummary, type IToolCallCompletedState, type ITurn } from '../common/state/sessionState.js'; +import { AgentSideEffects } from './agentSideEffects.js'; +import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.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)); + } + + // ---- 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(); + + // Overlay persisted custom titles from per-session databases. + const result = await Promise.all(flat.map(async s => { + try { + const ref = await this._sessionDataService.tryOpenDatabase(s.session); + if (!ref) { + return s; + } + try { + const customTitle = await ref.object.getMetadata('customTitle'); + if (customTitle) { + return { ...s, summary: customTitle }; + } + } finally { + ref.dispose(); + } + } catch { + // ignore — title overlay is best-effort + } + return s; + })); + + this._logService.trace(`[AgentService] listSessions returned ${result.length} sessions`); + return result; + } + + /** + * 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)'}`); + } + + // Ensure the command auto-approver is ready before any session events + // can arrive. This makes shell command auto-approval fully synchronous. + // Safe to run in parallel with createSession since no events flow until + // sendMessage() is called. + this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); + const [, session] = await Promise.all([ + this._sideEffects.initialize(), + provider.createSession(config), + ]); + this._logService.trace(`[AgentService] createSession: initialization complete`); + + this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`); + this._sessionToProvider.set(session.toString(), provider.id); + this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); + + // When forking, populate the new session's protocol state with + // the source session's turns so the client sees the forked history. + if (config?.fork) { + const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); + let sourceTurns: ITurn[] = []; + if (sourceState) { + sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1) + .map(t => ({ ...t, id: generateUuid() })); + } + + const summary: ISessionSummary = { + resource: session.toString(), + provider: provider.id, + title: sourceState?.summary.title ?? 'Forked Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: config.workingDirectory?.toString(), + }; + const state = this._stateManager.createSession(summary); + state.turns = sourceTurns; + } else { + // Create empty state for new sessions + const summary: ISessionSummary = { + resource: session.toString(), + provider: provider.id, + title: 'New Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory?.toString(), + }; + 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.deleteSession(session.toString()); + } + + // ---- Protocol methods --------------------------------------------------- + + async subscribe(resource: URI): Promise { + this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); + let snapshot = this._stateManager.getSnapshot(resource.toString()); + if (!snapshot) { + await this.restoreSession(resource); + 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 resourceList(uri: URI): Promise { + let stat; + try { + stat = await this._fileService.resolve(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 }; + } + + async restoreSession(session: URI): Promise { + const sessionStr = session.toString(); + + // Already in state manager - nothing to do. + if (this._stateManager.getSessionState(sessionStr)) { + return; + } + + const agent = this._findProviderForSession(session); + if (!agent) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`); + } + + // 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 ${sessionStr}: ${message}`); + } + const meta = allSessions.find(s => s.session.toString() === sessionStr); + if (!meta) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); + } + + let messages; + try { + messages = await agent.getSessionMessages(session); + } 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 ${sessionStr}: ${message}`); + } + const turns = this._buildTurnsFromMessages(messages); + + // Check for a persisted custom title in the session database + let title = meta.summary ?? 'Session'; + const ref = this._sessionDataService.tryOpenDatabase?.(session); + if (ref) { + try { + const db = await ref; + if (db) { + try { + const customTitle = await db.object.getMetadata('customTitle'); + if (customTitle) { + title = customTitle; + } + } finally { + db.dispose(); + } + } + } catch { + // Best-effort: fall back to agent-provided title + } + } + + const summary: ISessionSummary = { + resource: sessionStr, + provider: agent.id, + title, + status: SessionStatus.Idle, + createdAt: meta.startTime, + modifiedAt: meta.modifiedTime, + workingDirectory: meta.workingDirectory?.toString(), + }; + + this._stateManager.restoreSession(summary, turns); + this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); + } + + async resourceRead(uri: URI): Promise { + // Handle session-db: URIs that reference file-edit content stored + // in a per-session SQLite database. + const dbFields = parseSessionDbUri(uri.toString()); + if (dbFields) { + return this._fetchSessionDbContent(dbFields); + } + + try { + const content = await this._fileService.readFile(uri); + return { + data: content.value.toString(), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } catch (_e) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); + } + } + + async resourceWrite(params: IResourceWriteParams): Promise { + const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri); + let content: VSBuffer; + if (params.encoding === ContentEncoding.Base64) { + content = decodeBase64(params.data); + } else { + content = VSBuffer.fromString(params.data); + } + try { + if (params.createOnly) { + await this._fileService.createFile(fileUri, content, { overwrite: false }); + } else { + await this._fileService.writeFile(fileUri, content); + } + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to write file: ${fileUri.toString()}`); + } + } + + async resourceCopy(params: IResourceCopyParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.copy(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); + } + } + + async resourceDelete(params: IResourceDeleteParams): Promise { + const fileUri = URI.parse(params.uri); + try { + await this._fileService.del(fileUri, { recursive: params.recursive }); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`); + } + } + + async resourceMove(params: IResourceMoveParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.move(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.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 ------------------------------------------------------------ + + /** + * Reconstructs completed `ITurn[]` from a sequence of agent session + * messages. 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; + + const finalizeTurn = (turn: NonNullable, state: TurnState): void => { + turns.push({ + id: turn.id, + userMessage: turn.userMessage, + responseParts: turn.responseParts, + usage: undefined, + state, + }); + }; + + const startTurn = (id: string, text: string): NonNullable => ({ + id, + userMessage: { text }, + responseParts: [], + pendingTools: new Map(), + }); + + for (const msg of messages) { + if (msg.type === 'message' && msg.role === 'user') { + if (currentTurn) { + finalizeTurn(currentTurn, TurnState.Cancelled); + } + currentTurn = startTurn(msg.messageId, msg.content); + } else if (msg.type === 'message' && msg.role === 'assistant') { + if (!currentTurn) { + currentTurn = startTurn(msg.messageId, ''); + } + + 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; + } + + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { + const sessionUri = URI.parse(fields.sessionUri); + const ref = this._sessionDataService.openDatabase(sessionUri); + try { + const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath); + if (!content) { + throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); + } + const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent; + if (!bytes) { + throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); + } + return { + data: new TextDecoder().decode(bytes), + encoding: ContentEncoding.Utf8, + contentType: 'text/plain', + }; + } finally { + ref.dispose(); + } + } + + 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..8a3de0b49f03f --- /dev/null +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -0,0 +1,485 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { match as globMatch } from '../../../base/common/glob.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../base/common/observable.js'; +import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgent, IAgentAttachment, IAgentProgressEvent } from '../common/agentService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; +import { + CustomizationStatus, + PendingMessageKind, + type ISessionCustomization, + type ISessionModelInfo, + type URI as ProtocolURI, +} from '../common/state/sessionState.js'; +import { AgentEventMapper } from './agentEventMapper.js'; +import { CommandAutoApprover } from './commandAutoApprover.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, + * restores sessions from previous lifetimes, handles filesystem + * operations (browse/fetch/write), tracks pending permission requests, + * and wires up agent progress events to the state manager. + * + * Session create/dispose/list and auth are handled by {@link AgentService}. + */ +export class AgentSideEffects extends Disposable { + + /** 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(); + /** Auto-approver for shell commands parsed via tree-sitter. */ + private readonly _commandAutoApprover: CommandAutoApprover; + + constructor( + private readonly _stateManager: SessionStateManager, + private readonly _options: IAgentSideEffectsOptions, + private readonly _logService: ILogService, + ) { + super(); + this._commandAutoApprover = this._register(new CommandAutoApprover(this._logService)); + + // 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 }); + } + + // ---- Edit auto-approve -------------------------------------------------- + + /** + * Default edit auto-approve patterns applied by the agent host. + * Matches the VS Code `chat.tools.edits.autoApprove` setting defaults. + */ + private static readonly _DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { + '**/*': true, + '**/.vscode/*.json': false, + '**/.git/**': false, + '**/{package.json,server.xml,build.rs,web.config,.gitattributes,.env}': false, + '**/*.{code-workspace,csproj,fsproj,vbproj,vcxproj,proj,targets,props}': false, + '**/*.lock': false, + '**/*-lock.{yaml,json}': false, + }; + + /** + * Returns whether a write to `filePath` should be auto-approved based on + * the built-in default patterns. + */ + private _shouldAutoApproveEdit(filePath: string): boolean { + const patterns = AgentSideEffects._DEFAULT_EDIT_AUTO_APPROVE_PATTERNS; + let approved = true; + for (const [pattern, isApproved] of Object.entries(patterns)) { + if (isApproved !== approved && globMatch(pattern, filePath)) { + approved = isApproved; + } + } + return approved; + } + + /** + * Initializes async resources (tree-sitter WASM) used for command + * auto-approval. Await this before any session events can arrive to + * guarantee that {@link _tryAutoApproveToolReady} is fully synchronous. + */ + initialize(): Promise { + return this._commandAutoApprover.initialize(); + } + + /** + * Synchronously attempts to auto-approve a `tool_ready` event based on + * permission kind. Returns `true` if auto-approved (event should not be + * dispatched to the state manager), or `false` to proceed normally. + */ + private _tryAutoApproveToolReady( + e: { readonly toolCallId: string; readonly session: URI; readonly permissionKind?: string; readonly permissionPath?: string; readonly toolInput?: string }, + sessionKey: ProtocolURI, + agent: IAgent, + ): boolean { + // Write auto-approval: only within the session's working directory, + // then apply the default glob patterns for protected files. + if (e.permissionKind === 'write' && e.permissionPath) { + const sessionState = this._stateManager.getSessionState(sessionKey); + const workDir = sessionState?.workingDirectory ?? sessionState?.summary.workingDirectory; + const workingDirectory = workDir ? URI.parse(workDir) : undefined; + if (workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(e.permissionPath)), workingDirectory)) { + if (this._shouldAutoApproveEdit(e.permissionPath)) { + this._logService.trace(`[AgentSideEffects] Auto-approving write to ${e.permissionPath}`); + this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); + agent.respondToPermissionRequest(e.toolCallId, true); + return true; + } + } + return false; + } + + // Shell auto-approval: parse the command via tree-sitter (synchronous + // after initialize() has been awaited) and match against default rules. + if (e.permissionKind === 'shell' && e.toolInput) { + const result = this._commandAutoApprover.shouldAutoApprove(e.toolInput); + if (result === 'approved') { + this._logService.trace(`[AgentSideEffects] Auto-approving shell command`); + this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); + agent.respondToPermissionRequest(e.toolCallId, true); + return true; + } + if (result === 'denied') { + this._logService.trace(`[AgentSideEffects] Shell command denied by rule`); + } + return false; + } + + return false; + } + + // ---- 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) { + // Auto-approve tool_ready events synchronously before dispatching. + // Tree-sitter is pre-warmed via initialize(), so this is fully sync. + if (e.type === 'tool_ready') { + if (this._tryAutoApproveToolReady(e, sessionKey, agent)) { + return; + } + } + + this._dispatchProgressActions(agentMapper, e, sessionKey, turnId); + } + + // After a turn completes (idle event), try to consume the next queued message + if (e.type === 'idle') { + this._tryConsumeNextQueuedMessage(sessionKey); + } + + // Steering message was consumed by the agent — remove from protocol state + if (e.type === 'steering_consumed') { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionPendingMessageRemoved, + session: sessionKey, + kind: PendingMessageKind.Steering, + id: e.id, + }); + } + })); + return disposables; + } + + // ---- Side-effect handlers -------------------------------------------------- + + private _dispatchProgressActions(mapper: AgentEventMapper, e: IAgentProgressEvent, sessionKey: ProtocolURI, turnId: string): void { + const actions = mapper.mapProgressEventToActions(e, sessionKey, turnId); + if (actions) { + if (Array.isArray(actions)) { + for (const action of actions) { + this._stateManager.dispatchServerAction(action); + } + } else { + this._stateManager.dispatchServerAction(actions); + } + } + } + + 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; + } + case ActionType.SessionTitleChanged: { + this._persistTitle(action.session, action.title); + break; + } + case ActionType.SessionPendingMessageSet: + case ActionType.SessionPendingMessageRemoved: + case ActionType.SessionQueuedMessagesReordered: { + this._syncPendingMessages(action.session); + break; + } + case ActionType.SessionTruncated: { + const agent = this._options.getAgent(action.session); + let turnIndex: number | undefined; + if (action.turnId !== undefined) { + const state = this._stateManager.getSessionState(action.session); + if (state) { + const idx = state.turns.findIndex(t => t.id === action.turnId); + if (idx >= 0) { + turnIndex = idx; + } + } + } + agent?.truncateSession?.(URI.parse(action.session), turnIndex).catch(err => { + this._logService.error('[AgentSideEffects] truncateSession failed', err); + }); + break; + } + case ActionType.SessionActiveClientChanged: { + const agent = this._options.getAgent(action.session); + const refs = action.activeClient?.customizations; + if (!agent?.setClientCustomizations || !refs?.length) { + break; + } + // Publish initial "loading" status for all customizations + const loading: ISessionCustomization[] = refs.map(r => ({ + customization: r, + enabled: true, + status: CustomizationStatus.Loading, + })); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionCustomizationsChanged, + session: action.session, + customizations: loading, + }); + agent.setClientCustomizations( + action.activeClient!.clientId, + refs, + (synced) => { + // Incremental progress: publish updated statuses + const statuses: ISessionCustomization[] = synced.map(s => s.customization); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionCustomizationsChanged, + session: action.session, + customizations: statuses, + }); + }, + ).then(synced => { + // Final status + const statuses: ISessionCustomization[] = synced.map(s => s.customization); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionCustomizationsChanged, + session: action.session, + customizations: statuses, + }); + }).catch(err => { + this._logService.error('[AgentSideEffects] setClientCustomizations failed', err); + }); + break; + } + case ActionType.SessionCustomizationToggled: { + const agent = this._options.getAgent(action.session); + agent?.setCustomizationEnabled?.(action.uri, action.enabled); + break; + } + } + } + + private _persistTitle(session: ProtocolURI, title: string): void { + const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata('customTitle', title).catch(err => { + this._logService.warn('[AgentSideEffects] Failed to persist session title', err); + }).finally(() => { + ref.dispose(); + }); + } + + /** + * Pushes the current pending message state from the session to the agent. + * The server controls queued message consumption; only steering messages + * are forwarded to the agent for mid-turn injection. + */ + private _syncPendingMessages(session: ProtocolURI): void { + const state = this._stateManager.getSessionState(session); + if (!state) { + return; + } + const agent = this._options.getAgent(session); + agent?.setPendingMessages?.( + URI.parse(session), + state.steeringMessage, + [], + ); + + // Steering message removal is now dispatched by the agent + // via the 'steering_consumed' progress event once the message + // has actually been sent to the model. + + // If the session is idle, try to consume the next queued message + this._tryConsumeNextQueuedMessage(session); + } + + /** + * Consumes the next queued message by dispatching a server-initiated + * `SessionTurnStarted` action with `queuedMessageId` set. The reducer + * atomically creates the active turn and removes the message from the + * queue. Only consumes one message at a time; subsequent messages are + * consumed when the next `idle` event fires. + */ + private _tryConsumeNextQueuedMessage(session: ProtocolURI): void { + // Bail if there's already an active turn + if (this._stateManager.getActiveTurnId(session)) { + return; + } + const state = this._stateManager.getSessionState(session); + if (!state?.queuedMessages?.length) { + return; + } + + const msg = state.queuedMessages[0]; + const turnId = generateUuid(); + + // Reset event mappers for the new turn (same as handleAction does for SessionTurnStarted) + for (const mapper of this._eventMappers.values()) { + mapper.reset(session); + } + + // Dispatch server-initiated turn start; the reducer removes the queued message atomically + this._stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session, + turnId, + userMessage: msg.userMessage, + queuedMessageId: msg.id, + }); + + // Send the message to the agent backend + const agent = this._options.getAgent(session); + if (!agent) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionError, + session, + turnId, + error: { errorType: 'noAgent', message: 'No agent found for session' }, + }); + return; + } + const attachments = msg.userMessage.attachments?.map((a): IAgentAttachment => ({ + type: a.type, + path: a.path, + displayName: a.displayName, + })); + agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments).catch(err => { + this._logService.error('[AgentSideEffects] sendMessage failed (queued)', err); + this._stateManager.dispatchServerAction({ + type: ActionType.SessionError, + session, + turnId, + error: { errorType: 'sendFailed', message: String(err) }, + }); + }); + } + + override dispose(): void { + this._toolCallAgents.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/commandAutoApprover.ts b/src/vs/platform/agentHost/node/commandAutoApprover.ts new file mode 100644 index 0000000000000..f0ada0fe93676 --- /dev/null +++ b/src/vs/platform/agentHost/node/commandAutoApprover.ts @@ -0,0 +1,418 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm'; +import * as fs from 'fs'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { FileAccess } from '../../../base/common/network.js'; +import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; + +/** Pattern that detects compound commands (&&, ||, ;, |, backtick, $()) */ +const compoundCommandPattern = /&&|\|\||[;|]|`|\$\(/; + +/** + * Result of a command auto-approval check. + * - `approved`: all sub-commands match allow rules and none are denied + * - `denied`: at least one sub-command matches a deny rule + * - `noMatch`: no rule matched — requires user confirmation + */ +export type CommandApprovalResult = 'approved' | 'denied' | 'noMatch'; + +interface IAutoApproveRule { + readonly regex: RegExp; +} + +const neverMatchRegex = /(?!.*)/; +const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i; + +/** + * Auto-approves or denies shell commands based on default rules. + * + * Uses tree-sitter to parse compound commands (`foo && bar`) into + * sub-commands that are individually checked against allow/deny lists. + * The default rules mirror the VS Code `chat.tools.terminal.autoApprove` + * setting defaults. + * + * Tree-sitter is initialized eagerly; call {@link initialize} and await the + * result before using {@link shouldAutoApprove} to guarantee synchronous + * parsing. If tree-sitter failed to load, compound commands fall back to + * `noMatch` (user confirmation required). + */ +export class CommandAutoApprover extends Disposable { + + private _allowRules: IAutoApproveRule[] | undefined; + private _denyRules: IAutoApproveRule[] | undefined; + private _parser: Parser | undefined; + private _bashLanguage: Language | undefined; + private _queryClass: typeof Query | undefined; + private readonly _initPromise: Promise; + + constructor( + private readonly _logService: ILogService, + ) { + super(); + this._initPromise = this._initTreeSitter(); + } + + /** + * Returns a promise that resolves once tree-sitter WASM has been loaded. + * Await this before processing any events to guarantee that + * {@link shouldAutoApprove} can parse commands synchronously. + */ + initialize(): Promise { + return this._initPromise; + } + + /** + * Synchronously check whether the given command line should be auto-approved. + * Uses tree-sitter (if loaded) to parse compound commands into sub-commands. + */ + shouldAutoApprove(commandLine: string): CommandApprovalResult { + const trimmed = commandLine.trimStart(); + if (trimmed.length === 0) { + return 'approved'; + } + + this._ensureRules(); + + // Try to extract sub-commands via tree-sitter + const subCommands = this._extractSubCommands(trimmed); + if (subCommands && subCommands.length > 0) { + return this._matchSubCommands(subCommands); + } + + // Fallback: if this looks like a compound command but tree-sitter + // failed to parse it, require user confirmation rather than risking + // auto-approving a dangerous sub-command. + if (compoundCommandPattern.test(trimmed)) { + this._logService.trace('[CommandAutoApprover] Compound command without tree-sitter, requiring confirmation'); + return 'noMatch'; + } + + // Simple single command — match against rules + return this._matchCommandLine(trimmed); + } + + private _matchSubCommands(subCommands: string[]): CommandApprovalResult { + let allApproved = true; + for (const subCommand of subCommands) { + // Deny transient env var assignments + if (transientEnvVarRegex.test(subCommand)) { + return 'denied'; + } + + const result = this._matchSingleCommand(subCommand); + if (result === 'denied') { + return 'denied'; + } + if (result !== 'approved') { + allApproved = false; + } + } + return allApproved ? 'approved' : 'noMatch'; + } + + private _matchCommandLine(commandLine: string): CommandApprovalResult { + if (transientEnvVarRegex.test(commandLine)) { + return 'denied'; + } + return this._matchSingleCommand(commandLine); + } + + private _matchSingleCommand(command: string): CommandApprovalResult { + // Check deny rules first + for (const rule of this._denyRules!) { + if (rule.regex.test(command)) { + return 'denied'; + } + } + + // Then check allow rules + for (const rule of this._allowRules!) { + if (rule.regex.test(command)) { + return 'approved'; + } + } + + return 'noMatch'; + } + + // ---- Tree-sitter -------------------------------------------------------- + + private _extractSubCommands(commandLine: string): string[] | undefined { + if (!this._parser || !this._bashLanguage || !this._queryClass) { + return undefined; + } + + try { + this._parser.setLanguage(this._bashLanguage); + const tree = this._parser.parse(commandLine); + if (!tree) { + return undefined; + } + + try { + const query = new this._queryClass(this._bashLanguage, '(command) @command'); + const captures: QueryCapture[] = query.captures(tree.rootNode); + const subCommands = captures.map(c => c.node.text); + query.delete(); + return subCommands.length > 0 ? subCommands : undefined; + } finally { + tree.delete(); + } + } catch (err) { + this._logService.warn('[CommandAutoApprover] Tree-sitter parsing failed', err); + return undefined; + } + } + + private async _initTreeSitter(): Promise { + try { + const TreeSitter = await import('@vscode/tree-sitter-wasm'); + + // Resolve WASM files from node_modules + const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm'); + const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath; + + await TreeSitter.Parser.init({ + locateFile() { + return wasmPath; + } + }); + + const parser = new TreeSitter.Parser(); + this._register(toDisposable(() => parser.delete())); + + // Load bash grammar + const bashWasmPath = URI.joinPath(moduleRoot, 'tree-sitter-bash.wasm').fsPath; + const bashWasm = await fs.promises.readFile(bashWasmPath); + const bashLanguage = await TreeSitter.Language.load(new Uint8Array(bashWasm.buffer, bashWasm.byteOffset, bashWasm.byteLength)); + + this._parser = parser; + this._bashLanguage = bashLanguage; + this._queryClass = TreeSitter.Query; + this._logService.info('[CommandAutoApprover] Tree-sitter initialized successfully'); + } catch (err) { + this._logService.warn('[CommandAutoApprover] Failed to initialize tree-sitter', err); + } + } + + // ---- Rules -------------------------------------------------------------- + + private _ensureRules(): void { + if (this._allowRules && this._denyRules) { + return; + } + + const allowRules: IAutoApproveRule[] = []; + const denyRules: IAutoApproveRule[] = []; + + for (const [key, value] of Object.entries(DEFAULT_TERMINAL_AUTO_APPROVE_RULES)) { + const regex = convertAutoApproveEntryToRegex(key); + if (value === true) { + allowRules.push({ regex }); + } else if (value === false) { + denyRules.push({ regex }); + } + } + + this._allowRules = allowRules; + this._denyRules = denyRules; + } +} + +// ---- Regex conversion ------------------------------------------------------- + +function convertAutoApproveEntryToRegex(value: string): RegExp { + // If wrapped in `/`, treat as regex + const regexMatch = value.match(/^\/(?.+)\/(?[dgimsuvy]*)$/); + const regexPattern = regexMatch?.groups?.pattern; + if (regexPattern) { + let flags = regexMatch.groups?.flags; + if (flags) { + flags = flags.replaceAll('g', ''); + } + + if (regexPattern === '.*') { + return new RegExp(regexPattern); + } + + try { + const regex = new RegExp(regexPattern, flags || undefined); + if (regExpLeadsToEndlessLoop(regex)) { + return neverMatchRegex; + } + return regex; + } catch { + return neverMatchRegex; + } + } + + if (value === '') { + return neverMatchRegex; + } + + let sanitizedValue: string; + + // Match both path separators if it looks like a path + if (value.includes('/') || value.includes('\\')) { + let pattern = value.replace(/[/\\]/g, '%%PATH_SEP%%'); + pattern = escapeRegExpCharacters(pattern); + pattern = pattern.replace(/%%PATH_SEP%%*/g, '[/\\\\]'); + sanitizedValue = `^(?:\\.[/\\\\])?${pattern}`; + } else { + sanitizedValue = escapeRegExpCharacters(value); + } + + return new RegExp(`^${sanitizedValue}\\b`); +} + +// ---- Default rules ---------------------------------------------------------- +// +// These mirror the VS Code `chat.tools.terminal.autoApprove` setting defaults. +// Kept in sync manually — the actual setting will be wired up later. + +const DEFAULT_TERMINAL_AUTO_APPROVE_RULES: Readonly> = { + // Safe readonly commands + cd: true, + echo: true, + ls: true, + dir: true, + pwd: true, + cat: true, + head: true, + tail: true, + findstr: true, + wc: true, + tr: true, + cut: true, + cmp: true, + which: true, + basename: true, + dirname: true, + realpath: true, + readlink: true, + stat: true, + file: true, + od: true, + du: true, + df: true, + sleep: true, + nl: true, + + grep: true, + + // Safe git sub-commands + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+grep\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+branch\\b.*\\s-(d|D|m|M|-delete|-force)\\b/': false, + + // Docker readonly sub-commands + '/^docker\\s+(ps|images|info|version|inspect|logs|top|stats|port|diff|search|events)\\b/': true, + '/^docker\\s+(container|image|network|volume|context|system)\\s+(ls|ps|inspect|history|show|df|info)\\b/': true, + '/^docker\\s+compose\\s+(ps|ls|top|logs|images|config|version|port|events)\\b/': true, + + // PowerShell + 'Get-ChildItem': true, + 'Get-Content': true, + 'Get-Date': true, + 'Get-Random': true, + 'Get-Location': true, + 'Set-Location': true, + 'Write-Host': true, + 'Write-Output': true, + 'Out-String': true, + 'Split-Path': true, + 'Join-Path': true, + 'Start-Sleep': true, + 'Where-Object': true, + '/^Select-[a-z0-9]/i': true, + '/^Measure-[a-z0-9]/i': true, + '/^Compare-[a-z0-9]/i': true, + '/^Format-[a-z0-9]/i': true, + '/^Sort-[a-z0-9]/i': true, + + // Package manager read-only commands + '/^npm\\s+(ls|list|outdated|view|info|show|explain|why|root|prefix|bin|search|doctor|fund|repo|bugs|docs|home|help(-search)?)\\b/': true, + '/^npm\\s+config\\s+(list|get)\\b/': true, + '/^npm\\s+pkg\\s+get\\b/': true, + '/^npm\\s+audit$/': true, + '/^npm\\s+cache\\s+verify\\b/': true, + '/^yarn\\s+(list|outdated|info|why|bin|help|versions)\\b/': true, + '/^yarn\\s+licenses\\b/': true, + '/^yarn\\s+audit\\b(?!.*\\bfix\\b)/': true, + '/^yarn\\s+config\\s+(list|get)\\b/': true, + '/^yarn\\s+cache\\s+dir\\b/': true, + '/^pnpm\\s+(ls|list|outdated|why|root|bin|doctor)\\b/': true, + '/^pnpm\\s+licenses\\b/': true, + '/^pnpm\\s+audit\\b(?!.*\\bfix\\b)/': true, + '/^pnpm\\s+config\\s+(list|get)\\b/': true, + + // Safe lockfile-only installs + 'npm ci': true, + '/^yarn\\s+install\\s+--frozen-lockfile\\b/': true, + '/^pnpm\\s+install\\s+--frozen-lockfile\\b/': true, + + // Safe commands with dangerous arg blocking + column: true, + '/^column\\b.*\\s-c\\s+[0-9]{4,}/': false, + date: true, + '/^date\\b.*\\s(-s|--set)\\b/': false, + find: true, + '/^find\\b.*\\s-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false, + rg: true, + '/^rg\\b.*\\s(--pre|--hostname-bin)\\b/': false, + sed: true, + '/^sed\\b.*\\s(-[a-zA-Z]*(e|f)[a-zA-Z]*|--expression|--file)\\b/': false, + '/^sed\\b.*s\\/.*\\/.*\\/[ew]/': false, + '/^sed\\b.*;W/': false, + sort: true, + '/^sort\\b.*\\s-(o|S)\\b/': false, + tree: true, + '/^tree\\b.*\\s-o\\b/': false, + '/^xxd$/': true, + '/^xxd\\b(\\s+-\\S+)*\\s+[^-\\s]\\S*$/': true, + + // Dangerous commands + rm: false, + rmdir: false, + del: false, + 'Remove-Item': false, + ri: false, + rd: false, + erase: false, + dd: false, + kill: false, + ps: false, + top: false, + 'Stop-Process': false, + spps: false, + taskkill: false, + 'taskkill.exe': false, + curl: false, + wget: false, + 'Invoke-RestMethod': false, + 'Invoke-WebRequest': false, + irm: false, + iwr: false, + chmod: false, + chown: false, + 'Set-ItemProperty': false, + sp: false, + 'Set-Acl': false, + jq: false, + xargs: false, + eval: false, + 'Invoke-Expression': false, + iex: false, +}; 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..41869a4d4665a --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -0,0 +1,588 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CopilotClient } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; +import { SequencerByKey } 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 { generateUuid } from '../../../../base/common/uuid.js'; +import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { IFileService } from '../../../files/common/files.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { CustomizationStatus, ICustomizationRef, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; +import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; +import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js'; + +/** + * 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()); + private readonly _sessionSequencer = new SequencerByKey(); + private readonly _plugins: PluginController; + + constructor( + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IFileService private readonly _fileService: IFileService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); + this._plugins = this._instantiationService.createInstance(PluginController); + } + + // ---- 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'] = 'false'; + + // 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' ? URI.file(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 parsedPlugins = await this._plugins.getAppliedPlugins(); + + // When forking, we manipulate the CLI's on-disk data and then resume + // instead of creating a fresh session via the SDK. + if (config?.fork) { + const sourceSessionId = AgentSession.id(config.fork.session); + const newSessionId = config.session ? AgentSession.id(config.session) : generateUuid(); + + // Serialize against the source session to prevent concurrent + // modifications while we read its on-disk data. + return this._sessionSequencer.queue(sourceSessionId, async () => { + this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${config.fork!.turnIndex} → ${newSessionId}`); + + // Ensure the source session is loaded so on-disk data is available + if (!this._sessions.has(sourceSessionId)) { + await this._resumeSession(sourceSessionId); + } + + const copilotDataDir = getCopilotDataDir(); + await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, config.fork!.turnIndex); + + // Resume the forked session so the SDK loads the forked history + const agentSession = await this._resumeSession(newSessionId); + const session = agentSession.sessionUri; + this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); + return session; + }); + } + + const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); + const factory: SessionWrapperFactory = async callbacks => { + const customAgents = await toSdkCustomAgents(parsedPlugins.flatMap(p => p.agents), this._fileService); + const raw = await client.createSession({ + model: config?.model, + sessionId, + streaming: true, + workingDirectory: config?.workingDirectory?.fsPath, + onPermissionRequest: callbacks.onPermissionRequest, + hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks), + mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)), + customAgents, + skillDirectories: toSdkSkillDirectories(parsedPlugins.flatMap(p => p.skills)), + }); + return new CopilotSessionWrapper(raw); + }; + + const agentSession = this._createAgentSession(factory, config?.workingDirectory, sessionId); + this._plugins.setAppliedPlugins(agentSession, parsedPlugins); + await agentSession.initializeSession(); + + // Persist model & working directory so we can recreate the session + // if the SDK loses it (e.g. sessions without messages). + this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory); + + const session = agentSession.sessionUri; + this._logService.info(`[Copilot] Session created: ${session.toString()}`); + return session; + } + + async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { + return this._plugins.sync(clientId, customizations, progress); + } + + setCustomizationEnabled(uri: string, enabled: boolean): void { + this._plugins.setEnabled(uri, enabled); + } + + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + + // If plugin config changed, dispose this session so it gets resumed + // with the updated plugin primitives. + let entry = this._sessions.get(sessionId); + if (entry && await this._plugins.needsSessionRefresh(entry)) { + this._logService.info(`[Copilot:${sessionId}] Plugin config changed, refreshing session`); + this._sessions.deleteAndDispose(sessionId); + entry = undefined; + } + + entry ??= await this._resumeSession(sessionId); + await entry.send(prompt, attachments); + }); + } + + setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (!entry) { + this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`); + return; + } + + // Steering: send with mode 'immediate' so the SDK injects it mid-turn + if (steeringMessage) { + entry.sendSteering(steeringMessage); + } + + // Queued messages are consumed by the server (AgentSideEffects) + // which dispatches SessionTurnStarted and calls sendMessage directly. + // No SDK-level enqueue is needed. + } + + 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 []; + } + return entry.getMessages(); + } + + async disposeSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + this._sessions.deleteAndDispose(sessionId); + }); + } + + async abortSession(session: URI): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + const entry = this._sessions.get(sessionId); + if (entry) { + await entry.abort(); + } + }); + } + + async truncateSession(session: URI, turnIndex?: number): Promise { + const sessionId = AgentSession.id(session); + await this._sessionSequencer.queue(sessionId, async () => { + this._logService.info(`[Copilot:${sessionId}] Truncating session${turnIndex !== undefined ? ` at index ${turnIndex}` : ' (all turns)'}`); + + const keepUpToTurnIndex = turnIndex ?? -1; + + // Destroy the SDK session first and wait for cleanup to complete, + // ensuring on-disk data (events.jsonl, locks) is released before + // we modify it. Then dispose the wrapper. + const entry = this._sessions.get(sessionId); + if (entry) { + await entry.destroySession(); + } + this._sessions.deleteAndDispose(sessionId); + + const copilotDataDir = getCopilotDataDir(); + await truncateCopilotSessionOnDisk(copilotDataDir, sessionId, keepUpToTurnIndex); + + // Resume the session from the modified on-disk data + await this._resumeSession(sessionId); + this._logService.info(`[Copilot:${sessionId}] Session truncated and resumed`); + }); + } + + async forkSession(sourceSession: URI, newSessionId: string, turnIndex: number): Promise { + const sourceSessionId = AgentSession.id(sourceSession); + await this._sessionSequencer.queue(sourceSessionId, async () => { + this._logService.info(`[Copilot] Forking session ${sourceSessionId} at index ${turnIndex} → ${newSessionId}`); + + const copilotDataDir = getCopilotDataDir(); + await forkCopilotSessionOnDisk(copilotDataDir, sourceSessionId, newSessionId, turnIndex); + this._logService.info(`[Copilot] Forked session ${newSessionId} created on disk`); + }); + } + + async changeModel(session: URI, model: string): Promise { + const sessionId = AgentSession.id(session); + const entry = this._sessions.get(sessionId); + if (entry) { + await entry.setModel(model); + } + this._storeSessionMetadata(session, model, undefined); + } + + async shutdown(): Promise { + this._logService.info('[Copilot] Shutting down...'); + this._sessions.clearAndDisposeAll(); + await this._client?.stop(); + this._client = undefined; + } + + respondToPermissionRequest(requestId: string, approved: boolean): void { + for (const [, session] of this._sessions) { + if (session.respondToPermissionRequest(requestId, approved)) { + return; + } + } + } + + /** + * Returns true if this provider owns the given session ID. + */ + hasSession(session: URI): boolean { + return this._sessions.has(AgentSession.id(session)); + } + + // ---- helpers ------------------------------------------------------------ + + /** + * Creates a {@link CopilotAgentSession}, registers it in the sessions map, + * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} + * to wire up the SDK session. + */ + private _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: URI | undefined, sessionId: string): CopilotAgentSession { + const sessionUri = AgentSession.uri(this.id, sessionId); + + const agentSession = this._instantiationService.createInstance( + CopilotAgentSession, + sessionUri, + sessionId, + workingDirectory, + this._onDidSessionProgress, + wrapperFactory, + ); + + this._sessions.set(sessionId, agentSession); + return agentSession; + } + + private async _resumeSession(sessionId: string): Promise { + this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); + const client = await this._ensureClient(); + const parsedPlugins = await this._plugins.getAppliedPlugins(); + + const buildPluginConfig = async (callbacks: Parameters[0]) => { + const customAgents = await toSdkCustomAgents(parsedPlugins.flatMap(p => p.agents), this._fileService); + return { + onPermissionRequest: callbacks.onPermissionRequest, + hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks), + mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)), + customAgents, + skillDirectories: toSdkSkillDirectories(parsedPlugins.flatMap(p => p.skills)), + }; + }; + + const factory: SessionWrapperFactory = async callbacks => { + const pluginConfig = await buildPluginConfig(callbacks); + try { + const raw = await client.resumeSession(sessionId, { + ...pluginConfig, + }); + return new CopilotSessionWrapper(raw); + } catch (err) { + // The SDK fails to resume sessions that have no messages. + // Fall back to creating a new session with the same ID, + // seeding model & working directory from stored metadata. + if (!err || (err as { code?: number }).code !== -32603) { + throw err; + } + + this._logService.warn(`[Copilot:${sessionId}] Resume failed (session not found in SDK), recreating`); + const metadata = await this._readSessionMetadata(AgentSession.uri(this.id, sessionId)); + const raw = await client.createSession({ + ...pluginConfig, + sessionId, + streaming: true, + model: metadata.model, + workingDirectory: metadata.workingDirectory?.fsPath, + }); + + return new CopilotSessionWrapper(raw); + } + }; + + const agentSession = this._createAgentSession(factory, undefined, sessionId); + this._plugins.setAppliedPlugins(agentSession, parsedPlugins); + await agentSession.initializeSession(); + + return agentSession; + } + + // ---- session metadata persistence -------------------------------------- + + private static readonly _META_MODEL = 'copilot.model'; + private static readonly _META_CWD = 'copilot.workingDirectory'; + + private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined): void { + const dbRef = this._sessionDataService.tryOpenDatabase(session); + dbRef?.then(ref => { + if (!ref) { + return; + } + const db = ref.object; + const work: Promise[] = []; + if (model) { + work.push(db.setMetadata(CopilotAgent._META_MODEL, model)); + } + if (workingDirectory) { + work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); + } + Promise.all(work).finally(() => ref.dispose()); + }); + } + + private async _readSessionMetadata(session: URI): Promise<{ model?: string; workingDirectory?: URI }> { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return {}; + } + try { + const [model, cwd] = await Promise.all([ + ref.object.getMetadata(CopilotAgent._META_MODEL), + ref.object.getMetadata(CopilotAgent._META_CWD), + ]); + return { + model, + workingDirectory: cwd ? URI.parse(cwd) : undefined, + }; + } finally { + ref.dispose(); + } + } + + override dispose(): void { + this._client?.stop().catch(() => { /* best-effort */ }); + super.dispose(); + } +} + +class PluginController { + private readonly _enablement = new Map(); + private _lastSynced: Promise<{ synced: ISyncedCustomization[]; parsed: IParsedPlugin[] }> = Promise.resolve({ synced: [], parsed: [] }); + + /** Parsed plugin contents from the most recently applied sync. */ + private _appliedParsed = new WeakMap(); + + constructor( + @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, + @ILogService private readonly _logService: ILogService, + @IFileService private readonly _fileService: IFileService, + ) { } + + /** + * Returns true if the plugin configuration has changed since the last + * time sessions were created/resumed. Used by {@link CopilotAgent.sendMessage} + * to decide whether a session needs to be refreshed. + */ + public async needsSessionRefresh(session: CopilotAgentSession): Promise { + const { parsed } = await this._lastSynced; + return !parsedPluginsEqual(this._appliedParsed.get(session) || [], parsed); + } + + /** + * Returns the current parsed plugins filtered by enablement, + * then marks them as applied so {@link needsSessionRefresh} returns + * false until the next change. + */ + public async getAppliedPlugins(): Promise { + const { parsed } = await this._lastSynced; + return parsed; + } + + public setAppliedPlugins(session: CopilotAgentSession, plugins: readonly IParsedPlugin[]) { + this._appliedParsed.set(session, plugins); + } + + public setEnabled(pluginProtocolUri: string, enabled: boolean) { + this._enablement.set(pluginProtocolUri, enabled); + } + + public sync(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void) { + const prev = this._lastSynced; + const promise = this._lastSynced = prev.catch(() => []).then(async () => { + const result = await this._pluginManager.syncCustomizations(clientId, customizations, status => { + progress?.(status.map(c => ({ customization: c }))); + }); + + + const parsed: IParsedPlugin[] = []; + const synced: ISyncedCustomization[] = []; + for (const dir of result) { + if (dir.pluginDir) { + try { + parsed.push(await parsePlugin(dir.pluginDir, this._fileService, undefined, this._getUserHome())); + synced.push(dir); + } catch (e) { + this._logService.warn(`[Copilot:PluginController] Error parsing plugin: ${e}`); + synced.push({ customization: { ...dir.customization, status: CustomizationStatus.Error, statusMessage: `Error parsing plugin: ${e}` } }); + } + } else { + synced.push(dir); + } + } + + return { synced, parsed }; + }); + + return promise.then(p => p.synced); + } + + private _getUserHome(): string { + return process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts new file mode 100644 index 0000000000000..0631ba5103751 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentForking.ts @@ -0,0 +1,599 @@ +/*--------------------------------------------------------------------------------------------- + * 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 os from 'os'; +import type { Database } from '@vscode/sqlite3'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import * as path from '../../../../base/common/path.js'; + +// ---- Types ------------------------------------------------------------------ + +/** + * A single event entry from a Copilot CLI `events.jsonl` file. + * The Copilot CLI stores session history as a newline-delimited JSON log + * where events form a linked list via `parentId`. + */ +export interface ICopilotEventLogEntry { + readonly type: string; + readonly data: Record; + readonly id: string; + readonly timestamp: string; + readonly parentId: string | null; +} + +// ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- + +function dbExec(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => { + db.exec(sql, err => err ? reject(err) : resolve()); + }); +} + +function dbRun(db: Database, sql: string, params: unknown[]): Promise { + return new Promise((resolve, reject) => { + db.run(sql, params, function (err: Error | null) { + if (err) { + return reject(err); + } + resolve(); + }); + }); +} + +function dbAll(db: Database, sql: string, params: unknown[]): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, params, (err: Error | null, rows: Record[]) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); +} + +function dbClose(db: Database): Promise { + return new Promise((resolve, reject) => { + db.close(err => err ? reject(err) : resolve()); + }); +} + +function dbOpen(dbPath: string): Promise { + return new Promise((resolve, reject) => { + import('@vscode/sqlite3').then(sqlite3 => { + const db = new sqlite3.default.Database(dbPath, (err: Error | null) => { + if (err) { + return reject(err); + } + resolve(db); + }); + }, reject); + }); +} + +// ---- Pure functions (testable, no I/O) ------------------------------------ + +/** + * Parses a JSONL string into an array of event log entries. + */ +export function parseEventLog(content: string): ICopilotEventLogEntry[] { + const entries: ICopilotEventLogEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + entries.push(JSON.parse(trimmed)); + } + return entries; +} + +/** + * Serializes an array of event log entries back into a JSONL string. + */ +export function serializeEventLog(entries: readonly ICopilotEventLogEntry[]): string { + return entries.map(e => JSON.stringify(e)).join('\n') + '\n'; +} + +/** + * Finds the index of the last event that belongs to the given turn (0-based). + * + * A "turn" corresponds to one `user.message` event and all subsequent events + * up to (and including) the `assistant.turn_end` that closes that interaction, + * or the `session.shutdown` that ends the session. + * + * @returns The inclusive index of the last event in the specified turn, + * or `-1` if the turn is not found. + */ +export function findTurnBoundaryInEventLog(entries: readonly ICopilotEventLogEntry[], turnIndex: number): number { + let userMessageCount = -1; + let lastEventForTurn = -1; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + if (entry.type === 'user.message') { + userMessageCount++; + if (userMessageCount > turnIndex) { + // We've entered the next turn — stop + return lastEventForTurn; + } + } + + if (userMessageCount === turnIndex) { + lastEventForTurn = i; + } + } + + // If we scanned everything and the target turn was found, return its last event + return lastEventForTurn; +} + +/** + * Builds a forked event log from the source session's events. + * + * - Keeps events up to and including the specified fork turn (0-based). + * - Rewrites `session.start` with the new session ID. + * - Generates fresh UUIDs for all events. + * - Re-chains `parentId` links via an old→new ID map. + * - Strips `session.shutdown` and `session.resume` lifecycle events. + */ +export function buildForkedEventLog( + entries: readonly ICopilotEventLogEntry[], + forkTurnIndex: number, + newSessionId: string, +): ICopilotEventLogEntry[] { + const boundary = findTurnBoundaryInEventLog(entries, forkTurnIndex); + if (boundary < 0) { + throw new Error(`Fork turn index ${forkTurnIndex} not found in event log`); + } + + // Keep events up to boundary, filtering out lifecycle events + const kept = entries + .slice(0, boundary + 1) + .filter(e => e.type !== 'session.shutdown' && e.type !== 'session.resume'); + + // Build UUID remap and re-chain + const idMap = new Map(); + const result: ICopilotEventLogEntry[] = []; + + for (const entry of kept) { + const newId = generateUuid(); + idMap.set(entry.id, newId); + + let data = entry.data; + if (entry.type === 'session.start') { + data = { ...data, sessionId: newSessionId }; + } + + const newParentId = entry.parentId !== null + ? (idMap.get(entry.parentId) ?? result[result.length - 1]?.id ?? null) + : null; + + result.push({ + type: entry.type, + data, + id: newId, + timestamp: entry.timestamp, + parentId: newParentId, + }); + } + + return result; +} + +/** + * Builds a truncated event log from the source session's events. + * + * - Keeps events up to and including the specified turn (0-based). + * - Prepends a new `session.start` event using the original start data. + * - Re-chains `parentId` links for remaining events. + */ +export function buildTruncatedEventLog( + entries: readonly ICopilotEventLogEntry[], + keepUpToTurnIndex: number, +): ICopilotEventLogEntry[] { + const boundary = findTurnBoundaryInEventLog(entries, keepUpToTurnIndex); + if (boundary < 0) { + throw new Error(`Turn index ${keepUpToTurnIndex} not found in event log`); + } + + // Find the original session.start for its metadata + const originalStart = entries.find(e => e.type === 'session.start'); + if (!originalStart) { + throw new Error('No session.start event found in event log'); + } + + // Keep events from after session start up to boundary, stripping lifecycle events + const kept = entries + .slice(0, boundary + 1) + .filter(e => e.type !== 'session.start' && e.type !== 'session.shutdown' && e.type !== 'session.resume'); + + // Build new start event + const newStartId = generateUuid(); + const newStart: ICopilotEventLogEntry = { + type: 'session.start', + data: { ...originalStart.data, startTime: new Date().toISOString() }, + id: newStartId, + timestamp: new Date().toISOString(), + parentId: null, + }; + + // Re-chain: first remaining event points to the new start + const idMap = new Map(); + idMap.set(originalStart.id, newStartId); + + const result: ICopilotEventLogEntry[] = [newStart]; + let lastId = newStartId; + + for (const entry of kept) { + const newId = generateUuid(); + idMap.set(entry.id, newId); + + const newParentId = entry.parentId !== null + ? (idMap.get(entry.parentId) ?? lastId) + : lastId; + + result.push({ + type: entry.type, + data: entry.data, + id: newId, + timestamp: entry.timestamp, + parentId: newParentId, + }); + lastId = newId; + } + + return result; +} + +/** + * Generates a `workspace.yaml` file content for a Copilot CLI session. + */ +export function buildWorkspaceYaml(sessionId: string, cwd: string, summary: string): string { + const now = new Date().toISOString(); + return [ + `id: ${sessionId}`, + `cwd: ${cwd}`, + `summary_count: 0`, + `created_at: ${now}`, + `updated_at: ${now}`, + `summary: ${summary}`, + '', + ].join('\n'); +} + +// ---- SQLite operations (Copilot CLI session-store.db) --------------------- + +/** + * Forks a session record in the Copilot CLI's `session-store.db`. + * + * Copies the source session's metadata, turns (up to `forkTurnIndex`), + * session files, search index entries, and checkpoints into a new session. + */ +export async function forkSessionInDb( + db: Database, + sourceSessionId: string, + newSessionId: string, + forkTurnIndex: number, +): Promise { + await dbExec(db, 'PRAGMA foreign_keys = ON'); + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const now = new Date().toISOString(); + + // Copy session row + await dbRun(db, + `INSERT INTO sessions (id, cwd, repository, branch, summary, created_at, updated_at, host_type) + SELECT ?, cwd, repository, branch, summary, ?, ?, host_type + FROM sessions WHERE id = ?`, + [newSessionId, now, now, sourceSessionId], + ); + + // Copy turns up to fork point (turn_index is 0-based) + await dbRun(db, + `INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp) + SELECT ?, turn_index, user_message, assistant_response, timestamp + FROM turns + WHERE session_id = ? AND turn_index <= ?`, + [newSessionId, sourceSessionId, forkTurnIndex], + ); + + // Copy session files that were first seen at or before the fork point + await dbRun(db, + `INSERT INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at) + SELECT ?, file_path, tool_name, turn_index, first_seen_at + FROM session_files + WHERE session_id = ? AND turn_index <= ?`, + [newSessionId, sourceSessionId, forkTurnIndex], + ); + + // Copy search index entries for kept turns only. + // source_id format is ":turn:"; filter by + // parsing the turn index so we don't leak content from later turns. + await dbAll(db, + `SELECT content, source_type, source_id + FROM search_index + WHERE session_id = ? AND source_type = 'turn'`, + [sourceSessionId], + ).then(async rows => { + const prefix = `${sourceSessionId}:turn:`; + for (const row of rows) { + const sourceId = row.source_id as string; + if (sourceId.startsWith(prefix)) { + const turnIdx = parseInt(sourceId.substring(prefix.length), 10); + if (!isNaN(turnIdx) && turnIdx <= forkTurnIndex) { + const newSourceId = sourceId.replace(sourceSessionId, newSessionId); + await dbRun(db, + `INSERT INTO search_index (content, session_id, source_type, source_id) + VALUES (?, ?, ?, ?)`, + [row.content, newSessionId, row.source_type, newSourceId], + ); + } + } + } + }); + + // Copy checkpoints at or before the fork point. + // checkpoint_number is 1-based and correlates to turns, so we keep + // only those where checkpoint_number <= forkTurnIndex + 1. + await dbRun(db, + `INSERT INTO checkpoints (session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at) + SELECT ?, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at + FROM checkpoints + WHERE session_id = ? AND checkpoint_number <= ?`, + [newSessionId, sourceSessionId, forkTurnIndex + 1], + ); + + await dbExec(db, 'COMMIT'); + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } +} + +/** + * Truncates a session in the Copilot CLI's `session-store.db`. + * + * Removes all turns after `keepUpToTurnIndex` and updates session metadata. + */ +export async function truncateSessionInDb( + db: Database, + sessionId: string, + keepUpToTurnIndex: number, +): Promise { + await dbExec(db, 'PRAGMA foreign_keys = ON'); + await dbExec(db, 'BEGIN TRANSACTION'); + try { + const now = new Date().toISOString(); + + // Delete turns after the truncation point + await dbRun(db, + `DELETE FROM turns WHERE session_id = ? AND turn_index > ?`, + [sessionId, keepUpToTurnIndex], + ); + + // Update session timestamp + await dbRun(db, + `UPDATE sessions SET updated_at = ? WHERE id = ?`, + [now, sessionId], + ); + + // Remove search index entries for removed turns + // source_id format is ":turn:" + await dbAll(db, + `SELECT source_id FROM search_index + WHERE session_id = ? AND source_type = 'turn'`, + [sessionId], + ).then(async rows => { + const prefix = `${sessionId}:turn:`; + for (const row of rows) { + const sourceId = row.source_id as string; + if (sourceId.startsWith(prefix)) { + const turnIdx = parseInt(sourceId.substring(prefix.length), 10); + if (!isNaN(turnIdx) && turnIdx > keepUpToTurnIndex) { + await dbRun(db, + `DELETE FROM search_index WHERE source_id = ? AND session_id = ?`, + [sourceId, sessionId], + ); + } + } + } + }); + + await dbExec(db, 'COMMIT'); + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } +} + +// ---- File system operations ----------------------------------------------- + +/** + * Resolves the Copilot CLI data directory. + * The Copilot CLI stores its data in `~/.copilot/` by default, or in the + * directory specified by `COPILOT_CONFIG_DIR`. + */ +export function getCopilotDataDir(): string { + return process.env['COPILOT_CONFIG_DIR'] ?? path.join(os.homedir(), '.copilot'); +} + +/** + * Forks a Copilot CLI session on disk. + * + * 1. Reads the source session's `events.jsonl` + * 2. Builds a forked event log + * 3. Creates the new session folder with all required files/directories + * 4. Updates the `session-store.db` + * + * @param copilotDataDir Path to the `.copilot` directory + * @param sourceSessionId UUID of the source session to fork from + * @param newSessionId UUID for the new forked session + * @param forkTurnIndex 0-based turn index to fork at (inclusive) + */ +export async function forkCopilotSessionOnDisk( + copilotDataDir: string, + sourceSessionId: string, + newSessionId: string, + forkTurnIndex: number, +): Promise { + const sessionStateDir = path.join(copilotDataDir, 'session-state'); + + // Read source events + const sourceEventsPath = path.join(sessionStateDir, sourceSessionId, 'events.jsonl'); + const sourceContent = await fs.promises.readFile(sourceEventsPath, 'utf-8'); + const sourceEntries = parseEventLog(sourceContent); + + // Build forked event log + const forkedEntries = buildForkedEventLog(sourceEntries, forkTurnIndex, newSessionId); + + // Read source workspace.yaml for cwd/summary + let cwd = ''; + let summary = ''; + try { + const workspaceYamlPath = path.join(sessionStateDir, sourceSessionId, 'workspace.yaml'); + const yamlContent = await fs.promises.readFile(workspaceYamlPath, 'utf-8'); + const cwdMatch = yamlContent.match(/^cwd:\s*(.+)$/m); + const summaryMatch = yamlContent.match(/^summary:\s*(.+)$/m); + if (cwdMatch) { + cwd = cwdMatch[1].trim(); + } + if (summaryMatch) { + summary = summaryMatch[1].trim(); + } + } catch { + // Fall back to session.start data + const startEvent = sourceEntries.find(e => e.type === 'session.start'); + if (startEvent) { + const ctx = startEvent.data.context as Record | undefined; + cwd = ctx?.cwd ?? ''; + } + } + + // Create new session folder structure + const newSessionDir = path.join(sessionStateDir, newSessionId); + await fs.promises.mkdir(path.join(newSessionDir, 'checkpoints'), { recursive: true }); + await fs.promises.mkdir(path.join(newSessionDir, 'files'), { recursive: true }); + await fs.promises.mkdir(path.join(newSessionDir, 'research'), { recursive: true }); + + // Write events.jsonl + await fs.promises.writeFile( + path.join(newSessionDir, 'events.jsonl'), + serializeEventLog(forkedEntries), + 'utf-8', + ); + + // Write workspace.yaml + await fs.promises.writeFile( + path.join(newSessionDir, 'workspace.yaml'), + buildWorkspaceYaml(newSessionId, cwd, summary), + 'utf-8', + ); + + // Write empty vscode.metadata.json + await fs.promises.writeFile( + path.join(newSessionDir, 'vscode.metadata.json'), + '{}', + 'utf-8', + ); + + // Write empty checkpoints index + await fs.promises.writeFile( + path.join(newSessionDir, 'checkpoints', 'index.md'), + '', + 'utf-8', + ); + + // Update session-store.db + const dbPath = path.join(copilotDataDir, 'session-store.db'); + const db = await dbOpen(dbPath); + try { + await forkSessionInDb(db, sourceSessionId, newSessionId, forkTurnIndex); + } finally { + await dbClose(db); + } +} + +/** + * Truncates a Copilot CLI session on disk. + * + * 1. Reads the session's `events.jsonl` + * 2. Builds a truncated event log + * 3. Overwrites `events.jsonl` and updates `workspace.yaml` + * 4. Updates the `session-store.db` + * + * @param copilotDataDir Path to the `.copilot` directory + * @param sessionId UUID of the session to truncate + * @param keepUpToTurnIndex 0-based turn index to keep up to (inclusive) + */ +export async function truncateCopilotSessionOnDisk( + copilotDataDir: string, + sessionId: string, + keepUpToTurnIndex: number, +): Promise { + const sessionStateDir = path.join(copilotDataDir, 'session-state'); + const sessionDir = path.join(sessionStateDir, sessionId); + + // Read and truncate events + const eventsPath = path.join(sessionDir, 'events.jsonl'); + const content = await fs.promises.readFile(eventsPath, 'utf-8'); + const entries = parseEventLog(content); + + let truncatedEntries: ICopilotEventLogEntry[]; + if (keepUpToTurnIndex < 0) { + // Truncate all turns: keep only a fresh session.start event + const originalStart = entries.find(e => e.type === 'session.start'); + if (!originalStart) { + throw new Error('No session.start event found in event log'); + } + truncatedEntries = [{ + type: 'session.start', + data: { ...originalStart.data, startTime: new Date().toISOString() }, + id: generateUuid(), + timestamp: new Date().toISOString(), + parentId: null, + }]; + } else { + truncatedEntries = buildTruncatedEventLog(entries, keepUpToTurnIndex); + } + + // Overwrite events.jsonl + await fs.promises.writeFile(eventsPath, serializeEventLog(truncatedEntries), 'utf-8'); + + // Update workspace.yaml timestamp + try { + const yamlPath = path.join(sessionDir, 'workspace.yaml'); + let yaml = await fs.promises.readFile(yamlPath, 'utf-8'); + yaml = yaml.replace(/^updated_at:\s*.+$/m, `updated_at: ${new Date().toISOString()}`); + await fs.promises.writeFile(yamlPath, yaml, 'utf-8'); + } catch { + // workspace.yaml may not exist (old format) + } + + // Update session-store.db + const dbPath = path.join(copilotDataDir, 'session-store.db'); + const db = await dbOpen(dbPath); + try { + await truncateSessionInDb(db, sessionId, keepUpToTurnIndex); + } finally { + await dbClose(db); + } +} + +/** + * Maps a protocol turn ID to a 0-based turn index by finding the turn's + * position within the session's event log. + * + * The protocol state assigns arbitrary string IDs to turns, but the Copilot + * CLI's `events.jsonl` uses sequential `user.message` events. To bridge the + * two, we match turns by their position in the sequence. + * + * @returns The 0-based turn index, or `-1` if the turn ID is not found in the + * `turnIds` array. + */ +export function turnIdToIndex(turnIds: readonly string[], turnId: string): number { + return turnIds.indexOf(turnId); +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts new file mode 100644 index 0000000000000..a2a095a7ab219 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -0,0 +1,591 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { PermissionRequest, PermissionRequestResult } from '@github/copilot-sdk'; +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; +import { IFileService } from '../../../files/common/files.js'; +import { ILogService } from '../../../log/common/log.js'; +import { localize } from '../../../../nls.js'; +import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { ToolResultContentType, type IPendingMessage, type IToolResultContent } 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'; +import { mapSessionEvents } from './mapSessionEvents.js'; + +/** + * Factory function that produces a {@link CopilotSessionWrapper}. + * Called by {@link CopilotAgentSession.initializeSession} with the + * session's permission handler and edit-tracking hooks so the factory + * can wire them into the SDK session it creates. + * + * In production, the factory calls `CopilotClient.createSession()` or + * `resumeSession()`. In tests, it returns a mock wrapper directly. + */ +export type SessionWrapperFactory = (callbacks: { + readonly onPermissionRequest: (request: PermissionRequest) => Promise; + readonly hooks: { + readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + }; +}) => Promise; + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +/** + * Derives display fields from a permission request for the tool confirmation UI. + */ +function 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, + }; + } +} + +/** + * Encapsulates a single Copilot SDK session and all its associated bookkeeping. + * + * Created by {@link CopilotAgent}, one instance per active session. Disposing + * this class tears down all per-session resources (SDK wrapper, edit tracker, + * database reference, pending permissions). + */ +export class CopilotAgentSession extends Disposable { + readonly sessionId: string; + readonly sessionUri: URI; + + /** Tracks active tool invocations so we can produce past-tense messages on completion. */ + private readonly _activeToolCalls = new Map | undefined }>(); + /** Pending permission requests awaiting a renderer-side decision. */ + private readonly _pendingPermissions = new Map>(); + /** File edit tracker for this session. */ + private readonly _editTracker: FileEditTracker; + /** Session database reference. */ + private readonly _databaseRef: IReference; + /** Turn ID tracked across tool events. */ + private _turnId = ''; + /** SDK session wrapper, set by {@link initializeSession}. */ + private _wrapper!: CopilotSessionWrapper; + + private readonly _workingDirectory: URI | undefined; + + constructor( + sessionUri: URI, + rawSessionId: string, + workingDirectory: URI | undefined, + private readonly _onDidSessionProgress: Emitter, + private readonly _wrapperFactory: SessionWrapperFactory, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + @ISessionDataService sessionDataService: ISessionDataService, + ) { + super(); + this.sessionId = rawSessionId; + this.sessionUri = sessionUri; + this._workingDirectory = workingDirectory; + + this._databaseRef = sessionDataService.openDatabase(sessionUri); + this._register(toDisposable(() => this._databaseRef.dispose())); + + this._editTracker = new FileEditTracker(sessionUri.toString(), this._databaseRef.object, this._fileService, this._logService); + + this._register(toDisposable(() => this._denyPendingPermissions())); + } + + /** + * Creates (or resumes) the SDK session via the injected factory and + * wires up all event listeners. Must be called exactly once after + * construction before using the session. + */ + async initializeSession(): Promise { + this._wrapper = this._register(await this._wrapperFactory({ + onPermissionRequest: request => this.handlePermissionRequest(request), + hooks: { + onPreToolUse: async input => { + if (isEditTool(input.toolName)) { + const filePath = getEditFilePath(input.toolArgs); + if (filePath) { + await this._editTracker.trackEditStart(filePath); + } + } + }, + onPostToolUse: async input => { + if (isEditTool(input.toolName)) { + const filePath = getEditFilePath(input.toolArgs); + if (filePath) { + await this._editTracker.completeEdit(filePath); + } + } + }, + }, + })); + this._subscribeToEvents(); + this._subscribeForLogging(); + } + + // ---- session operations ------------------------------------------------- + + async send(prompt: string, attachments?: IAgentAttachment[]): Promise { + this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); + + 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:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); + } + + await this._wrapper.session.send({ prompt, attachments: sdkAttachments }); + this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); + } + + async sendSteering(steeringMessage: IPendingMessage): Promise { + this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); + try { + await this._wrapper.session.send({ + prompt: steeringMessage.userMessage.text, + mode: 'immediate', + }); + this._onDidSessionProgress.fire({ + session: this.sessionUri, + type: 'steering_consumed', + id: steeringMessage.id, + }); + } catch (err) { + this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err); + } + } + + async getMessages(): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + const events = await this._wrapper.session.getMessages(); + let db: ISessionDatabase | undefined; + try { + db = this._databaseRef.object; + } catch { + // Database may not exist yet — that's fine + } + return mapSessionEvents(this.sessionUri, db, events); + } + + async abort(): Promise { + this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); + this._denyPendingPermissions(); + await this._wrapper.session.abort(); + } + + /** + * Explicitly destroys the underlying SDK session and waits for cleanup + * to complete. Call this before {@link dispose} when you need to ensure + * the session's on-disk data is no longer locked (e.g. before + * truncation or fork operations that modify the session files). + */ + async destroySession(): Promise { + await this._wrapper.session.destroy(); + } + + async setModel(model: string): Promise { + this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`); + await this._wrapper.session.setModel(model); + } + + // ---- permission handling ------------------------------------------------ + + /** + * 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 {@link respondToPermissionRequest}. + */ + async handlePermissionRequest( + request: PermissionRequest, + ): Promise { + this._logService.info(`[Copilot:${this.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; + if (requestPath && this._workingDirectory && extUriBiasedIgnorePathCase.isEqualOrParent(normalizePath(URI.file(requestPath)), this._workingDirectory)) { + this._logService.trace(`[Copilot:${this.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:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`); + return { kind: 'denied-interactively-by-user' }; + } + + this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`); + + const deferred = new DeferredPromise(); + this._pendingPermissions.set(toolCallId, deferred); + + // Derive display information from the permission request kind + const { confirmationTitle, invocationMessage, toolInput } = getPermissionDisplay(request); + + // Fire a tool_ready event to transition the tool to PendingConfirmation + this._onDidSessionProgress.fire({ + session: this.sessionUri, + type: 'tool_ready', + toolCallId, + invocationMessage, + toolInput, + confirmationTitle, + permissionKind: request.kind, + permissionPath: typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined), + }); + + const approved = await deferred.p; + this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`); + return { kind: approved ? 'approved' : 'denied-interactively-by-user' }; + } + + respondToPermissionRequest(requestId: string, approved: boolean): boolean { + const deferred = this._pendingPermissions.get(requestId); + if (deferred) { + this._pendingPermissions.delete(requestId); + deferred.complete(approved); + return true; + } + return false; + } + + // ---- event wiring ------------------------------------------------------- + + private _subscribeToEvents(): void { + const wrapper = this._wrapper; + const sessionId = this.sessionId; + const session = this.sessionUri; + + this._register(wrapper.onMessageDelta(e => { + this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`); + this._onDidSessionProgress.fire({ + session, + type: 'delta', + messageId: e.data.messageId, + content: e.data.deltaContent, + parentToolCallId: e.data.parentToolCallId, + }); + })); + + this._register(wrapper.onMessage(e => { + this._logService.info(`[Copilot:${sessionId}] 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, + }); + })); + + this._register(wrapper.onToolStart(e => { + if (isHiddenTool(e.data.toolName)) { + this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); + return; + } + this._logService.info(`[Copilot:${sessionId}] 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); + this._activeToolCalls.set(e.data.toolCallId, { 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, + }); + })); + + this._register(wrapper.onTurnStart(e => { + this._turnId = e.data.turnId; + })); + + this._register(wrapper.onToolComplete(e => { + const tracked = this._activeToolCalls.get(e.data.toolCallId); + if (!tracked) { + return; + } + this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); + this._activeToolCalls.delete(e.data.toolCallId); + 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 filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined; + if (filePath) { + const fileEdit = this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, 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, + }); + })); + + this._register(wrapper.onIdle(() => { + this._logService.info(`[Copilot:${sessionId}] Session idle`); + this._onDidSessionProgress.fire({ session, type: 'idle' }); + })); + + this._register(wrapper.onSessionError(e => { + this._logService.error(`[Copilot:${sessionId}] 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, + }); + })); + + this._register(wrapper.onUsage(e => { + this._logService.trace(`[Copilot:${sessionId}] 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, + }); + })); + + this._register(wrapper.onReasoningDelta(e => { + this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); + this._onDidSessionProgress.fire({ + session, + type: 'reasoning', + content: e.data.deltaContent, + }); + })); + } + + private _subscribeForLogging(): void { + const wrapper = this._wrapper; + const sessionId = this.sessionId; + + this._register(wrapper.onSessionStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`); + })); + + this._register(wrapper.onSessionResume(e => { + this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`); + })); + + this._register(wrapper.onSessionInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`); + })); + + this._register(wrapper.onSessionModelChange(e => { + this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); + })); + + this._register(wrapper.onSessionHandoff(e => { + this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`); + })); + + this._register(wrapper.onSessionTruncation(e => { + this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`); + })); + + this._register(wrapper.onSessionSnapshotRewind(e => { + this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`); + })); + + this._register(wrapper.onSessionShutdown(e => { + this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`); + })); + + this._register(wrapper.onSessionUsageInfo(e => { + this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`); + })); + + this._register(wrapper.onSessionCompactionStart(() => { + this._logService.trace(`[Copilot:${sessionId}] Compaction started`); + })); + + this._register(wrapper.onSessionCompactionComplete(e => { + this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`); + })); + + this._register(wrapper.onUserMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); + })); + + this._register(wrapper.onPendingMessagesModified(() => { + this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`); + })); + + this._register(wrapper.onTurnStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); + })); + + this._register(wrapper.onIntent(e => { + this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`); + })); + + this._register(wrapper.onReasoning(e => { + this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`); + })); + + this._register(wrapper.onTurnEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); + })); + + this._register(wrapper.onAbort(e => { + this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`); + })); + + this._register(wrapper.onToolUserRequested(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`); + })); + + this._register(wrapper.onToolPartialResult(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`); + })); + + this._register(wrapper.onToolProgress(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`); + })); + + this._register(wrapper.onSkillInvoked(e => { + this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); + })); + + this._register(wrapper.onSubagentStarted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`); + })); + + this._register(wrapper.onSubagentCompleted(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`); + })); + + this._register(wrapper.onSubagentFailed(e => { + this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`); + })); + + this._register(wrapper.onSubagentSelected(e => { + this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`); + })); + + this._register(wrapper.onHookStart(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`); + })); + + this._register(wrapper.onHookEnd(e => { + this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`); + })); + + this._register(wrapper.onSystemMessage(e => { + this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`); + })); + } + + // ---- cleanup ------------------------------------------------------------ + + private _denyPendingPermissions(): void { + for (const [, deferred] of this._pendingPermissions) { + deferred.complete(false); + } + this._pendingPermissions.clear(); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts new file mode 100644 index 0000000000000..c08d7e5f479ff --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { spawn } from 'child_process'; +import type { CustomAgentConfig, MCPServerConfig, SessionConfig } from '@github/copilot-sdk'; +import { OperatingSystem, OS } from '../../../../base/common/platform.js'; +import { IFileService } from '../../../files/common/files.js'; +import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedHookCommand, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { dirname } from '../../../../base/common/path.js'; + +type SessionHooks = NonNullable; + +// --------------------------------------------------------------------------- +// MCP servers +// --------------------------------------------------------------------------- + +/** + * Converts parsed MCP server definitions into the SDK's `mcpServers` config. + */ +export function toSdkMcpServers(defs: readonly IMcpServerDefinition[]): Record { + const result: Record = {}; + for (const def of defs) { + const config = def.configuration; + if (config.type === McpServerType.LOCAL) { + result[def.name] = { + type: 'local', + command: config.command, + args: config.args ? [...config.args] : [], + tools: ['*'], + ...(config.env && { env: toStringEnv(config.env) }), + ...(config.cwd && { cwd: config.cwd }), + }; + } else { + result[def.name] = { + type: 'http', + url: config.url, + tools: ['*'], + ...(config.headers && { headers: { ...config.headers } }), + }; + } + } + return result; +} + +/** + * Ensures all env values are strings (the SDK requires `Record`). + */ +function toStringEnv(env: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== null) { + result[key] = String(value); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Custom agents +// --------------------------------------------------------------------------- + +/** + * Converts parsed plugin agents into the SDK's `customAgents` config. + * Reads each agent's `.md` file to use as the prompt. + */ +export async function toSdkCustomAgents(agents: readonly INamedPluginResource[], fileService: IFileService): Promise { + const configs: CustomAgentConfig[] = []; + for (const agent of agents) { + try { + const content = await fileService.readFile(agent.uri); + configs.push({ + name: agent.name, + prompt: content.value.toString(), + }); + } catch { + // Skip agents whose file cannot be read + } + } + return configs; +} + +// --------------------------------------------------------------------------- +// Skill directories +// --------------------------------------------------------------------------- + +/** + * Converts parsed plugin skills into the SDK's `skillDirectories` config. + * The SDK expects directory paths; we extract the parent directory of each SKILL.md. + */ +export function toSdkSkillDirectories(skills: readonly INamedPluginResource[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const skill of skills) { + // SKILL.md parent directory is the skill directory + const dir = dirname(skill.uri.fsPath); + if (!seen.has(dir)) { + seen.add(dir); + result.push(dir); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** + * Resolves the effective command for the current platform from a parsed hook command. + */ +function resolveEffectiveCommand(hook: IParsedHookCommand, os: OperatingSystem): string | undefined { + if (os === OperatingSystem.Windows && hook.windows) { + return hook.windows; + } else if (os === OperatingSystem.Macintosh && hook.osx) { + return hook.osx; + } else if (os === OperatingSystem.Linux && hook.linux) { + return hook.linux; + } + return hook.command; +} + +/** + * Executes a hook command as a shell process. Returns the stdout on success, + * or throws on non-zero exit code or timeout. + */ +function executeHookCommand(hook: IParsedHookCommand, stdin?: string): Promise { + const command = resolveEffectiveCommand(hook, OS); + if (!command) { + return Promise.resolve(''); + } + + const timeout = (hook.timeout ?? 30) * 1000; + const cwd = hook.cwd?.fsPath; + + return new Promise((resolve, reject) => { + const isWindows = OS === OperatingSystem.Windows; + const shell = isWindows ? 'cmd.exe' : '/bin/sh'; + const shellArgs = isWindows ? ['/c', command] : ['-c', command]; + + const child = spawn(shell, shellArgs, { + cwd, + env: { ...process.env, ...hook.env }, + stdio: ['pipe', 'pipe', 'pipe'], + timeout, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + + if (stdin) { + child.stdin.write(stdin); + child.stdin.end(); + } else { + child.stdin.end(); + } + + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Hook command exited with code ${code}: ${stderr || stdout}`)); + } + }); + }); +} + +/** + * Mapping from canonical hook type identifiers to SDK SessionHooks handler keys. + */ +const HOOK_TYPE_TO_SDK_KEY: Record = { + 'PreToolUse': 'onPreToolUse', + 'PostToolUse': 'onPostToolUse', + 'UserPromptSubmit': 'onUserPromptSubmitted', + 'SessionStart': 'onSessionStart', + 'SessionEnd': 'onSessionEnd', + 'ErrorOccurred': 'onErrorOccurred', +}; + +/** + * Converts parsed plugin hooks into SDK {@link SessionHooks} handler functions. + * + * Each handler executes the hook's shell commands sequentially when invoked. + * Hook types that don't map to SDK handler keys are silently ignored. + * + * The optional `editTrackingHooks` parameter provides internal edit-tracking + * callbacks from {@link CopilotAgentSession} that are merged with plugin hooks. + */ +export function toSdkHooks( + hookGroups: readonly IParsedHookGroup[], + editTrackingHooks?: { + readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + }, +): SessionHooks { + // Group all commands by SDK handler key + const commandsByKey = new Map(); + for (const group of hookGroups) { + const sdkKey = HOOK_TYPE_TO_SDK_KEY[group.type]; + if (!sdkKey) { + continue; + } + const existing = commandsByKey.get(sdkKey) ?? []; + existing.push(...group.commands); + commandsByKey.set(sdkKey, existing); + } + + const hooks: SessionHooks = {}; + + // Pre-tool-use handler + const preToolCommands = commandsByKey.get('onPreToolUse'); + if (preToolCommands?.length || editTrackingHooks) { + hooks.onPreToolUse = async (input: { toolName: string; toolArgs: unknown }) => { + await editTrackingHooks?.onPreToolUse(input); + if (preToolCommands) { + const stdin = JSON.stringify(input); + for (const cmd of preToolCommands) { + try { + const output = await executeHookCommand(cmd, stdin); + if (output.trim()) { + try { + const parsed = JSON.parse(output); + if (parsed && typeof parsed === 'object') { + return parsed; + } + } catch { + // Non-JSON output is fine — no modification + } + } + } catch { + // Hook failures are non-fatal + } + } + } + }; + } + + // Post-tool-use handler + const postToolCommands = commandsByKey.get('onPostToolUse'); + if (postToolCommands?.length || editTrackingHooks) { + hooks.onPostToolUse = async (input: { toolName: string; toolArgs: unknown }) => { + await editTrackingHooks?.onPostToolUse(input); + if (postToolCommands) { + const stdin = JSON.stringify(input); + for (const cmd of postToolCommands) { + try { + await executeHookCommand(cmd, stdin); + } catch { + // Hook failures are non-fatal + } + } + } + }; + } + + // User-prompt-submitted handler + const promptCommands = commandsByKey.get('onUserPromptSubmitted'); + if (promptCommands?.length) { + hooks.onUserPromptSubmitted = async (input: { prompt: string }) => { + const stdin = JSON.stringify(input); + for (const cmd of promptCommands) { + try { + await executeHookCommand(cmd, stdin); + } catch { + // Hook failures are non-fatal + } + } + }; + } + + // Session-start handler + const startCommands = commandsByKey.get('onSessionStart'); + if (startCommands?.length) { + hooks.onSessionStart = async (input: { source: string }) => { + const stdin = JSON.stringify(input); + for (const cmd of startCommands) { + try { + await executeHookCommand(cmd, stdin); + } catch { + // Hook failures are non-fatal + } + } + }; + } + + // Session-end handler + const endCommands = commandsByKey.get('onSessionEnd'); + if (endCommands?.length) { + hooks.onSessionEnd = async (input: { reason: string }) => { + const stdin = JSON.stringify(input); + for (const cmd of endCommands) { + try { + await executeHookCommand(cmd, stdin); + } catch { + // Hook failures are non-fatal + } + } + }; + } + + // Error-occurred handler + const errorCommands = commandsByKey.get('onErrorOccurred'); + if (errorCommands?.length) { + hooks.onErrorOccurred = async (input: { error: string }) => { + const stdin = JSON.stringify(input); + for (const cmd of errorCommands) { + try { + await executeHookCommand(cmd, stdin); + } catch { + // Hook failures are non-fatal + } + } + }; + } + + return hooks; +} + +/** + * Checks whether two sets of parsed plugins produce equivalent SDK config. + * Used to determine if a session needs to be refreshed. + */ +export function parsedPluginsEqual(a: readonly IParsedPlugin[], b: readonly IParsedPlugin[]): boolean { + // Simple structural comparison via JSON serialization. + // We serialize only the essential fields, replacing URIs with strings. + const serialize = (plugins: readonly IParsedPlugin[]) => { + return JSON.stringify(plugins.map(p => ({ + hooks: p.hooks.map(h => ({ type: h.type, commands: h.commands.map(c => ({ command: c.command, windows: c.windows, linux: c.linux, osx: c.osx, cwd: c.cwd?.toString(), env: c.env, timeout: c.timeout })) })), + mcpServers: p.mcpServers.map(m => ({ name: m.name, configuration: m.configuration })), + skills: p.skills.map(s => ({ uri: s.uri.toString(), name: s.name })), + agents: p.agents.map(a => ({ uri: a.uri.toString(), name: a.name })), + }))); + }; + return serialize(a) === serialize(b); +} 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..69b401e6069ac --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeHex, encodeHex, 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 { ISessionDatabase } from '../../common/sessionDataService.js'; +import { FileEditKind, ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; + +const SESSION_DB_SCHEME = 'session-db'; + +/** + * Builds a `session-db:` URI that references a file-edit content blob + * stored in the session database. Parsed by {@link parseSessionDbUri}. + */ +export function buildSessionDbUri(sessionUri: string, toolCallId: string, filePath: string, part: 'before' | 'after'): string { + return URI.from({ + scheme: SESSION_DB_SCHEME, + authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), + path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}/${part}`, + }).toString(); +} + +/** Parsed fields from a `session-db:` content URI. */ +export interface ISessionDbUriFields { + sessionUri: string; + toolCallId: string; + filePath: string; + part: 'before' | 'after'; +} + +/** + * Parses a `session-db:` URI produced by {@link buildSessionDbUri}. + * Returns `undefined` if the URI is not a valid `session-db:` URI. + */ +export function parseSessionDbUri(raw: string): ISessionDbUriFields | undefined { + const parsed = URI.parse(raw); + if (parsed.scheme !== SESSION_DB_SCHEME) { + return undefined; + } + const [, toolCallId, filePath, part] = parsed.path.split('/'); + if (!toolCallId || !filePath || (part !== 'before' && part !== 'after')) { + return undefined; + } + try { + return { + sessionUri: decodeHex(parsed.authority).toString(), + toolCallId: decodeURIComponent(toolCallId), + filePath: decodeHex(filePath).toString(), + part + }; + } catch { + return undefined; + } +} + +/** + * Tracks file edits made by tools in a session by snapshotting file content + * before and after each edit tool invocation, persisting snapshots into the + * session database. + */ +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 from the + * `onToolComplete` handler and persists to the database. + */ + private readonly _completedEdits = new Map(); + + constructor( + private readonly _sessionUri: string, + private readonly _db: ISessionDatabase, + private readonly _fileService: IFileService, + private readonly _logService: ILogService, + ) { } + + /** + * Call from the `onPreToolUse` hook before an edit tool runs. + * Reads the file's current content into memory 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 snapshotDone = this._readFile(filePath); + const entry = { beforeContent: VSBuffer.fromString(''), snapshotDone: snapshotDone.then(buf => { entry.beforeContent = buf; }) }; + this._pendingEdits.set(filePath, entry); + await entry.snapshotDone; + } + + /** + * Call from the `onPostToolUse` hook after an edit tool finishes. + * Reads the file content again as the "after" state and stores the + * result for later retrieval via {@link takeCompletedEdit}. + * + * @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; + + const afterContent = await this._readFile(filePath); + + this._completedEdits.set(filePath, { + beforeContent: pending.beforeContent, + afterContent, + }); + } + + /** + * Retrieves and removes a completed edit for the given file path, + * persists it to the session database, and returns the result as an + * {@link IToolResultFileEditContent} for inclusion in the tool result. + * + * @param toolCallId - The tool call that produced this edit. + * @param filePath - Absolute path of the edited file. + */ + takeCompletedEdit(turnId: string, toolCallId: string, filePath: string): IToolResultFileEditContent | undefined { + const edit = this._completedEdits.get(filePath); + if (!edit) { + return undefined; + } + this._completedEdits.delete(filePath); + + const beforeBytes = edit.beforeContent.buffer; + const afterBytes = edit.afterContent.buffer; + + this._db.storeFileEdit({ + turnId, + toolCallId, + filePath, + kind: FileEditKind.Edit, + beforeContent: beforeBytes, + afterContent: afterBytes, + addedLines: undefined, + removedLines: undefined, + }).catch(err => this._logService.warn(`[FileEditTracker] Failed to persist file edit to database: ${filePath}`, err)); + + return { + type: ToolResultContentType.FileEdit, + before: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before') }, + }, + after: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after') }, + }, + }; + } + + private async _readFile(filePath: string): Promise { + try { + const content = await this._fileService.readFile(URI.file(filePath)); + return content.value; + } catch (err) { + this._logService.trace(`[FileEditTracker] Could not read file for snapshot: ${filePath}`, err); + return VSBuffer.fromString(''); + } + } +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts new file mode 100644 index 0000000000000..4a651512eb821 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.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. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { IAgentMessageEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; +import { ToolResultContentType, type IToolResultContent } from '../../common/state/sessionState.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; +import { buildSessionDbUri } from './fileEditTracker.js'; + +function tryStringify(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +// ---- Minimal event shapes matching the SDK's SessionEvent union --------- +// Defined here so tests can construct events without importing the SDK. + +export interface ISessionEventToolStart { + type: 'tool.execution_start'; + data: { + toolCallId: string; + toolName: string; + arguments?: unknown; + mcpServerName?: string; + mcpToolName?: string; + parentToolCallId?: string; + }; +} + +export interface ISessionEventToolComplete { + type: 'tool.execution_complete'; + data: { + toolCallId: string; + success: boolean; + result?: { content?: string }; + error?: { message: string; code?: string }; + isUserRequested?: boolean; + toolTelemetry?: unknown; + parentToolCallId?: string; + }; +} + +export interface ISessionEventMessage { + type: 'assistant.message' | 'user.message'; + data?: { + messageId?: string; + interactionId?: string; + content?: string; + toolRequests?: readonly { toolCallId: string; name: string; arguments?: unknown; type?: 'function' | 'custom' }[]; + reasoningOpaque?: string; + reasoningText?: string; + encryptedContent?: string; + parentToolCallId?: string; + }; +} + +/** Minimal event shape for session history mapping. */ +export type ISessionEvent = ISessionEventToolStart | ISessionEventToolComplete | ISessionEventMessage | { type: string; data?: unknown }; + +/** + * Maps raw SDK session events into agent protocol events, restoring + * stored file-edit metadata from the session database when available. + * + * Extracted as a standalone function so it can be tested without the + * full CopilotAgent or SDK dependencies. + */ +export async function mapSessionEvents( + session: URI, + db: ISessionDatabase | undefined, + events: readonly ISessionEvent[], +): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { + const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[] = []; + const toolInfoByCallId = new Map | undefined }>(); + + // Collect all tool call IDs for edit tools so we can batch-query the database + const editToolCallIds: string[] = []; + + // First pass: collect tool info and identify edit tool calls + for (const e of events) { + if (e.type === 'tool.execution_start') { + const d = (e as ISessionEventToolStart).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 }); + if (isEditTool(d.toolName)) { + editToolCallIds.push(d.toolCallId); + } + } + } + + // Query the database for stored file edits (metadata only) + let storedEdits: Map | undefined; + if (db && editToolCallIds.length > 0) { + try { + const records = await db.getFileEdits(editToolCallIds); + if (records.length > 0) { + storedEdits = new Map(); + for (const r of records) { + let list = storedEdits.get(r.toolCallId); + if (!list) { + list = []; + storedEdits.set(r.toolCallId, list); + } + list.push(r); + } + } + } catch (_e) { + // Database may not exist yet for new sessions — that's fine + } + } + + const sessionUriStr = session.toString(); + + // Second pass: build result events + for (const e of events) { + if (e.type === 'assistant.message' || e.type === 'user.message') { + const d = (e as ISessionEventMessage).data; + result.push({ + session, + type: 'message', + role: e.type === 'user.message' ? 'user' : 'assistant', + messageId: d?.messageId ?? d?.interactionId ?? '', + content: d?.content ?? '', + toolRequests: d?.toolRequests?.map((tr) => ({ + 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 ISessionEventToolStart).data; + if (isHiddenTool(d.toolName)) { + continue; + } + const info = toolInfoByCallId.get(d.toolCallId); + const displayName = getToolDisplayName(d.toolName); + const toolKind = getToolKind(d.toolName); + const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + result.push({ + session, + type: 'tool_start', + toolCallId: d.toolCallId, + toolName: d.toolName, + displayName, + invocationMessage: getInvocationMessage(d.toolName, displayName, info?.parameters), + toolInput: getToolInputString(d.toolName, info?.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 ISessionEventToolComplete).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 }); + } + + // Restore file edit content references from the database + const edits = storedEdits?.get(d.toolCallId); + if (edits) { + for (const edit of edits) { + const beforeUri = edit.kind === 'rename' && edit.originalPath + ? URI.file(edit.originalPath).toString() + : URI.file(edit.filePath).toString(); + const afterUri = URI.file(edit.filePath).toString(); + const hasBefore = edit.kind !== 'create'; + const hasAfter = edit.kind !== 'delete'; + content.push({ + type: ToolResultContentType.FileEdit, + before: hasBefore ? { + uri: beforeUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, + } : undefined, + after: hasAfter ? { + uri: afterUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, + } : undefined, + diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) + ? { added: edit.addedLines, removed: edit.removedLines } + : undefined, + }); + } + } + + 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, + parentToolCallId: d.parentToolCallId, + }); + } + } + return result; +} 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..311d823078540 --- /dev/null +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -0,0 +1,563 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { hasKey } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; +import { AgentSession, type IAgentService, type IAuthenticateParams } 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 { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; +import { + AHP_PROVIDER_NOT_FOUND, + AHP_SESSION_NOT_FOUND, + AHP_UNSUPPORTED_PROTOCOL_VERSION, + IJsonRpcRequest, + isJsonRpcNotification, + isJsonRpcRequest, + JSON_RPC_INTERNAL_ERROR, + ProtocolError, + type IAhpServerNotification, + type IInitializeParams, + type IJsonRpcResponse, + type IReconnectParams, + type IStateSnapshot, +} from '../common/state/sessionProtocol.js'; +import { ROOT_STATE_URI, SessionStatus } 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; +} + +/** + * Configuration for protocol-level concerns outside of IAgentService. + */ +export interface IProtocolServerConfig { + /** Default directory returned to clients during the initialize handshake. */ + readonly defaultDirectory?: string; +} + +/** + * Server-side handler that manages protocol connections, routes JSON-RPC + * messages to the agent service, 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 _agentService: IAgentService, + private readonly _stateManager: SessionStateManager, + private readonly _server: IProtocolServer, + private readonly _config: IProtocolServerConfig, + private readonly _clientFileSystemProvider: AHPFileSystemProvider, + @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 action = msg.params.action as ISessionAction; + this._agentService.dispatchAction(action, client.clientId, msg.params.clientSeq); + } + break; + } + } else if (isJsonRpcResponse(msg)) { + const pending = this._pendingReverseRequests.get(msg.id); + if (pending) { + this._pendingReverseRequests.delete(msg.id); + if (hasKey(msg, { error: true })) { + pending.reject(new Error(msg.error?.message ?? 'Reverse RPC error')); + } else { + pending.resolve(msg.result); + } + } + } + })); + + 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._rejectPendingReverseRequests(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); + + disposables.add(this._clientFileSystemProvider.registerAuthority(params.clientId, { + resourceList: (uri) => this._sendReverseRequest(params.clientId, 'resourceList', { uri: uri.toString() }), + resourceRead: (uri) => this._sendReverseRequest(params.clientId, 'resourceRead', { uri: uri.toString() }), + resourceWrite: (params_) => this._sendReverseRequest(params.clientId, 'resourceWrite', params_), + resourceDelete: (params_) => this._sendReverseRequest(params.clientId, 'resourceDelete', params_), + resourceMove: (params_) => this._sendReverseRequest(params.clientId, 'resourceMove', params_), + })); + + + 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._config.defaultDirectory, + }, + }; + } + + 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) => { + try { + const snapshot = await this._agentService.subscribe(URI.parse(params.resource)); + client.subscriptions.add(params.resource); + return { snapshot }; + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Resource not found: ${params.resource}`); + } + }, + createSession: async (_client, params) => { + let createdSession: URI; + // Resolve fork turnId to a 0-based index using the source session's + // turn list in the state manager. + let fork: { session: URI; turnIndex: number } | undefined; + if (params.fork) { + const sourceState = this._stateManager.getSessionState(params.fork.session); + if (!sourceState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork source session not found: ${params.fork.session}`); + } + const turnIndex = sourceState.turns.findIndex(t => t.id === params.fork!.turnId); + if (turnIndex < 0) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Fork turn ID ${params.fork.turnId} not found in session ${params.fork.session}`); + } + fork = { session: URI.parse(params.fork.session), turnIndex }; + } + try { + createdSession = await this._agentService.createSession({ + provider: params.provider, + model: params.model, + workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, + session: URI.parse(params.session), + fork, + }); + } catch (err) { + if (err instanceof ProtocolError) { + throw err; + } + throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, err instanceof Error ? err.message : String(err)); + } + // Verify the provider honored the client-chosen session URI per the protocol contract + if (createdSession.toString() !== URI.parse(params.session).toString()) { + this._logService.warn(`[ProtocolServer] createSession: provider returned URI ${createdSession.toString()} but client requested ${params.session}`); + } + return null; + }, + disposeSession: async (_client, params) => { + await this._agentService.disposeSession(URI.parse(params.session)); + return null; + }, + resourceWrite: async (_client, params) => { + return this._agentService.resourceWrite(params); + }, + listSessions: async () => { + const sessions = await this._agentService.listSessions(); + const items = 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?.toString(), + })); + 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, + }; + }, + resourceList: async (_client, params) => { + return this._agentService.resourceList(URI.parse(params.uri)); + }, + resourceRead: async (_client, params) => { + return this._agentService.resourceRead(URI.parse(params.uri)); + }, + resourceCopy: async (_client, params) => { + return this._agentService.resourceCopy(params); + }, + resourceDelete: async (_client, params) => { + return this._agentService.resourceDelete(params); + }, + resourceMove: async (_client, params) => { + return this._agentService.resourceMove(params); + }, + }; + + + // ---- Reverse RPC (server → client requests) ---------------------------- + + private _reverseRequestId = 0; + private readonly _pendingReverseRequests = new Map void; reject: (reason: unknown) => void }>(); + + /** + * Sends a JSON-RPC request to a connected client and waits for the response. + * Used for reverse-RPC operations like reading client-side files. + * Rejects if the client disconnects or the server is disposed. + */ + private _sendReverseRequest(clientId: string, method: string, params: unknown): Promise { + const client = this._clients.get(clientId); + if (!client) { + return Promise.reject(new Error(`Client ${clientId} is not connected`)); + } + const id = ++this._reverseRequestId; + return new Promise((resolve, reject) => { + this._pendingReverseRequests.set(id, { clientId, resolve: resolve as (value: unknown) => void, reject }); + const request: IJsonRpcRequest = { jsonrpc: '2.0', id, method, params }; + client.transport.send(request); + }); + } + + /** + * Rejects and clears all pending reverse-RPC requests for a given client. + */ + private _rejectPendingReverseRequests(clientId: string): void { + for (const [id, pending] of this._pendingReverseRequests) { + if (pending.clientId === clientId) { + this._pendingReverseRequests.delete(id); + pending.reject(new Error(`Client ${clientId} disconnected`)); + } + } + } + + 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 this._agentService.getResourceMetadata(); + case 'authenticate': { + const authParams = params as IAuthenticateParams; + if (!authParams || typeof authParams.resource !== 'string' || typeof authParams.token !== 'string') { + return Promise.reject(new ProtocolError(-32602, 'Invalid authenticate params')); + } + return this._agentService.authenticate(authParams); + } + case 'refreshModels': + return this._agentService.refreshModels(); + case 'listAgents': + return this._agentService.listAgents(); + case 'shutdown': + return this._agentService.shutdown(); + 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 (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(); + for (const [, pending] of this._pendingReverseRequests) { + pending.reject(new Error('ProtocolServerHandler disposed')); + } + this._pendingReverseRequests.clear(); + this._replayBuffer.length = 0; + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/serverUrls.ts b/src/vs/platform/agentHost/node/serverUrls.ts new file mode 100644 index 0000000000000..7112746bc929a --- /dev/null +++ b/src/vs/platform/agentHost/node/serverUrls.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import * as os from 'os'; + +export interface IResolvedServerUrls { + readonly local: readonly string[]; + readonly network: readonly string[]; +} + +const loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '0000:0000:0000:0000:0000:0000:0000:0001']); +const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); + +export function resolveServerUrls(host: string | undefined, port: number, networkInterfaces: ReturnType = os.networkInterfaces()): IResolvedServerUrls { + if (host === undefined) { + return { local: [formatWebSocketUrl('localhost', port)], network: [] }; + } + + if (!wildcardHosts.has(host)) { + const url = formatWebSocketUrl(host, port); + return loopbackHosts.has(host) + ? { local: [url], network: [] } + : { local: [], network: [url] }; + } + + const network = new Set(); + for (const netInterface of Object.values(networkInterfaces)) { + for (const detail of netInterface ?? []) { + if (detail.family !== 'IPv4' || detail.internal) { + continue; + } + + network.add(formatWebSocketUrl(detail.address, port)); + } + } + + return { + local: [formatWebSocketUrl('localhost', port)], + network: [...network], + }; +} + +export function formatWebSocketUrl(host: string, port: number): string { + const normalizedHost = host.includes(':') ? `[${host}]` : host; + return `ws://${normalizedHost}:${port}`; +} diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts new file mode 100644 index 0000000000000..875b149813ec5 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionDataService.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. + *--------------------------------------------------------------------------------------------*/ + +import { IReference, ReferenceCollection } from '../../../base/common/lifecycle.js'; +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 { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; +import { SessionDatabase } from './sessionDatabase.js'; + +class SessionDatabaseCollection extends ReferenceCollection { + constructor( + private readonly _getDbPath: (key: string) => string, + private readonly _logService: ILogService, + ) { + super(); + } + + protected createReferencedObject(key: string): ISessionDatabase { + const dbPath = this._getDbPath(key); + this._logService.trace(`[SessionDataService] Opening database: ${dbPath}`); + return new SessionDatabase(dbPath); + } + + protected destroyReferencedObject(_key: string, object: ISessionDatabase): void { + object.dispose(); + } +} + +/** + * 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; + private readonly _databases: SessionDatabaseCollection; + + constructor( + userDataPath: URI, + @IFileService private readonly _fileService: IFileService, + @ILogService private readonly _logService: ILogService, + getDbPath?: (key: string) => string, // for testing + ) { + this._basePath = URI.joinPath(userDataPath, 'agentSessionData'); + this._databases = new SessionDatabaseCollection( + getDbPath ?? (key => URI.joinPath(this._basePath, key, 'session.db').fsPath), + this._logService, + ); + } + + 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); + } + + private _sanitizedSessionKey(session: URI): string { + return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } + + openDatabase(session: URI): IReference { + return this._databases.acquire(this._sanitizedSessionKey(session)); + } + + async tryOpenDatabase(session: URI): Promise | undefined> { + const key = this._sanitizedSessionKey(session); + const dbPath = URI.joinPath(this._basePath, key, 'session.db'); + if (!await this._fileService.exists(dbPath)) { + return undefined; + } + return this._databases.acquire(key); + } + + 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/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts new file mode 100644 index 0000000000000..aab5aff8025c4 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SequencerByKey } from '../../../base/common/async.js'; +import type { Database, RunResult } from '@vscode/sqlite3'; +import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import { dirname } from '../../../base/common/path.js'; + +/** + * A single numbered migration. Migrations are applied in order of + * {@link version} and tracked via `PRAGMA user_version`. + */ +export interface ISessionDatabaseMigration { + /** Monotonically-increasing version number (1-based). */ + readonly version: number; + /** SQL to execute for this migration. */ + readonly sql: string; +} + +/** + * The set of migrations that define the current session database schema. + * New migrations should be **appended** to this array with the next version + * number. Never reorder or mutate existing entries. + */ +export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ + { + version: 1, + sql: [ + `CREATE TABLE IF NOT EXISTS turns ( + id TEXT PRIMARY KEY NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS file_edits ( + turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + tool_call_id TEXT NOT NULL, + file_path TEXT NOT NULL, + before_content BLOB NOT NULL, + after_content BLOB NOT NULL, + added_lines INTEGER, + removed_lines INTEGER, + PRIMARY KEY (tool_call_id, file_path) + )`, + ].join(';\n'), + }, + { + version: 2, + sql: `CREATE TABLE IF NOT EXISTS session_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + )`, + }, + { + version: 3, + sql: [ + // Recreate file_edits with new columns: edit_type, original_path, + // and nullable before_content/after_content. + `CREATE TABLE file_edits_v3 ( + turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + tool_call_id TEXT NOT NULL, + file_path TEXT NOT NULL, + edit_type TEXT NOT NULL DEFAULT 'edit', + original_path TEXT, + before_content BLOB, + after_content BLOB, + added_lines INTEGER, + removed_lines INTEGER, + PRIMARY KEY (tool_call_id, file_path) + )`, + `INSERT INTO file_edits_v3 (turn_id, tool_call_id, file_path, edit_type, before_content, after_content, added_lines, removed_lines) + SELECT turn_id, tool_call_id, file_path, 'edit', before_content, after_content, added_lines, removed_lines FROM file_edits`, + `DROP TABLE file_edits`, + `ALTER TABLE file_edits_v3 RENAME TO file_edits`, + ].join(';\n'), + }, +]; + +// ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- + +function dbExec(db: Database, sql: string): Promise { + return new Promise((resolve, reject) => { + db.exec(sql, err => err ? reject(err) : resolve()); + }); +} + +function dbRun(db: Database, sql: string, params: unknown[]): Promise<{ changes: number; lastID: number }> { + return new Promise((resolve, reject) => { + db.run(sql, params, function (this: RunResult, err: Error | null) { + if (err) { + return reject(err); + } + resolve({ changes: this.changes, lastID: this.lastID }); + }); + }); +} + +function dbGet(db: Database, sql: string, params: unknown[]): Promise | undefined> { + return new Promise((resolve, reject) => { + db.get(sql, params, (err: Error | null, row: Record | undefined) => { + if (err) { + return reject(err); + } + resolve(row); + }); + }); +} + +function dbAll(db: Database, sql: string, params: unknown[]): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, params, (err: Error | null, rows: Record[]) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); +} + +function dbClose(db: Database): Promise { + return new Promise((resolve, reject) => { + db.close(err => err ? reject(err) : resolve()); + }); +} + +function dbOpen(path: string): Promise { + return new Promise((resolve, reject) => { + import('@vscode/sqlite3').then(sqlite3 => { + const db = new sqlite3.default.Database(path, (err: Error | null) => { + if (err) { + return reject(err); + } + resolve(db); + }); + }, reject); + }); +} + +/** + * Applies any pending {@link ISessionDatabaseMigration migrations} to a + * database. Migrations whose version is greater than the current + * `PRAGMA user_version` are run inside a serialized transaction. After all + * migrations complete the pragma is updated to the highest applied version. + */ +export async function runMigrations(db: Database, migrations: readonly ISessionDatabaseMigration[]): Promise { + // Enable foreign key enforcement — must be set outside a transaction + // and every time a connection is opened. + await dbExec(db, 'PRAGMA foreign_keys = ON'); + + const row = await dbGet(db, 'PRAGMA user_version', []); + const currentVersion = (row?.user_version as number | undefined) ?? 0; + + const pending = migrations + .filter(m => m.version > currentVersion) + .sort((a, b) => a.version - b.version); + + if (pending.length === 0) { + return; + } + + await dbExec(db, 'BEGIN TRANSACTION'); + try { + for (const migration of pending) { + await dbExec(db, migration.sql); + // PRAGMA cannot be parameterized; the version is a trusted literal. + await dbExec(db, `PRAGMA user_version = ${migration.version}`); + } + await dbExec(db, 'COMMIT'); + } catch (err) { + await dbExec(db, 'ROLLBACK'); + throw err; + } +} + +/** + * A wrapper around a `@vscode/sqlite3` {@link Database} instance with + * lazy initialisation. + * + * The underlying connection is opened on the first async method call + * (not at construction time), allowing the object to be created + * synchronously and shared via a {@link ReferenceCollection}. + * + * Calling {@link dispose} closes the connection. + */ +export class SessionDatabase implements ISessionDatabase { + + protected _dbPromise: Promise | undefined; + protected _closed: Promise | true | undefined; + private readonly _fileEditSequencer = new SequencerByKey(); + + constructor( + private readonly _path: string, + private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, + ) { } + + /** + * Opens (or creates) a SQLite database at {@link path} and applies + * any pending migrations. Only used in tests where synchronous + * construction + immediate readiness is desired. + */ + static async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { + const inst = new SessionDatabase(path, migrations); + await inst._ensureDb(); + return inst; + } + + protected _ensureDb(): Promise { + if (this._closed) { + return Promise.reject(new Error('SessionDatabase has been disposed')); + } + if (!this._dbPromise) { + this._dbPromise = (async () => { + // Ensure the parent directory exists before SQLite tries to + // create the database file. + await fs.promises.mkdir(dirname(this._path), { recursive: true }); + const db = await dbOpen(this._path); + try { + await runMigrations(db, this._migrations); + } catch (err) { + await dbClose(db); + this._dbPromise = undefined; + throw err; + } + // If dispose() was called while we were opening, close immediately. + if (this._closed) { + await dbClose(db); + throw new Error('SessionDatabase has been disposed'); + } + return db; + })(); + } + return this._dbPromise; + } + + /** + * Returns the names of all user-created tables in the database. + * Useful for testing migration behavior. + */ + async getAllTables(): Promise { + const db = await this._ensureDb(); + const rows = await dbAll(db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`, []); + return rows.map(r => r.name as string); + } + + // ---- Turns ---------------------------------------------------------- + + async createTurn(turnId: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + } + + async deleteTurn(turnId: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]); + } + + // ---- File edits ----------------------------------------------------- + + async storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise { + return this._fileEditSequencer.queue(edit.filePath, async () => { + const db = await this._ensureDb(); + // Ensure the turn exists — the onTurnStart event that calls + // createTurn() is fire-and-forget and may not have completed yet. + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]); + await dbRun( + db, + `INSERT OR REPLACE INTO file_edits + (turn_id, tool_call_id, file_path, edit_type, original_path, before_content, after_content, added_lines, removed_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + edit.turnId, + edit.toolCallId, + edit.filePath, + edit.kind, + edit.originalPath ?? null, + edit.beforeContent ? Buffer.from(edit.beforeContent) : null, + edit.afterContent ? Buffer.from(edit.afterContent) : null, + edit.addedLines ?? null, + edit.removedLines ?? null, + ], + ); + }); + } + + async getFileEdits(toolCallIds: string[]): Promise { + if (toolCallIds.length === 0) { + return []; + } + const db = await this._ensureDb(); + const placeholders = toolCallIds.map(() => '?').join(','); + const rows = await dbAll( + db, + `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines + FROM file_edits + WHERE tool_call_id IN (${placeholders}) + ORDER BY rowid`, + toolCallIds, + ); + return rows.map(row => ({ + turnId: row.turn_id as string, + toolCallId: row.tool_call_id as string, + filePath: row.file_path as string, + kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', + originalPath: row.original_path as string | undefined ?? undefined, + addedLines: row.added_lines as number | undefined ?? undefined, + removedLines: row.removed_lines as number | undefined ?? undefined, + })); + } + + async readFileEditContent(toolCallId: string, filePath: string): Promise { + return this._fileEditSequencer.queue(filePath, async () => { + const db = await this._ensureDb(); + const row = await dbGet( + db, + `SELECT before_content, after_content + FROM file_edits + WHERE tool_call_id = ? AND file_path = ?`, + [toolCallId, filePath], + ); + if (!row) { + return undefined; + } + return { + beforeContent: row.before_content ? toUint8Array(row.before_content) : undefined, + afterContent: row.after_content ? toUint8Array(row.after_content) : undefined, + }; + }); + } + + // ---- Session metadata ----------------------------------------------- + + async getMetadata(key: string): Promise { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT value FROM session_metadata WHERE key = ?', [key]); + return row?.value as string | undefined; + } + + async setMetadata(key: string, value: string): Promise { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]); + } + + async close() { + await (this._closed ??= this._dbPromise?.then(db => db.close()).catch(() => { }) || true); + } + + dispose(): void { + this.close(); + } +} + +function toUint8Array(value: unknown): Uint8Array { + if (value instanceof Buffer) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (value instanceof Uint8Array) { + return value; + } + if (typeof value === 'string') { + return new TextEncoder().encode(value); + } + return new Uint8Array(0); +} diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts new file mode 100644 index 0000000000000..21e2091cca086 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -0,0 +1,259 @@ +/*--------------------------------------------------------------------------------------------- + * 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 in-memory state without emitting a notification. + * Use {@link deleteSession} when the session is being permanently deleted + * and clients need to be notified. + */ + 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}`); + } + + /** + * Permanently deletes a session from state and emits a + * {@link NotificationType.SessionRemoved} notification so that clients + * know the session is no longer accessible. + */ + deleteSession(session: URI): void { + this.removeSession(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/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts new file mode 100644 index 0000000000000..4e6aec9b66d6b --- /dev/null +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -0,0 +1,686 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type WebSocket from 'ws'; +import { createRequire } from 'node:module'; +import { promises as fsp } from 'fs'; +import * as os from 'os'; +import * as cp from 'child_process'; +import { dirname, join, isAbsolute, basename } from '../../../base/common/path.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import { IProductService } from '../../product/common/productService.js'; +import { + ISSHRemoteAgentHostMainService, + SSHAuthMethod, + type ISSHAgentHostConfig, + type ISSHAgentHostConfigSanitized, + type ISSHConnectProgress, + type ISSHConnectResult, + type ISSHRelayMessage, + type ISSHResolvedConfig, +} from '../common/sshRemoteAgentHost.js'; + +const _require = createRequire(import.meta.url); + +/** Minimal subset of ssh2.ClientChannel used by this module (duplex stream). */ +interface SSHChannel extends NodeJS.ReadWriteStream { + on(event: 'data', listener: (data: Buffer) => void): this; + on(event: 'close', listener: (code: number) => void): this; + on(event: 'error', listener: (err: Error) => void): this; + on(event: string, listener: (...args: unknown[]) => void): this; + stderr: { on(event: 'data', listener: (data: Buffer) => void): void }; + close(): void; +} + +/** Minimal subset of ssh2.Client used by this module. */ +interface SSHClient { + on(event: 'ready', listener: () => void): SSHClient; + on(event: 'error', listener: (err: Error) => void): SSHClient; + on(event: 'close', listener: () => void): SSHClient; + connect(config: Record): void; + exec(command: string, callback: (err: Error | undefined, stream: SSHChannel) => void): SSHClient; + forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number, callback: (err: Error | undefined, channel: SSHChannel) => void): SSHClient; + end(): void; +} + +const LOG_PREFIX = '[SSHRemoteAgentHost]'; + +/** + * Validate that a quality string is safe for bare interpolation in shell commands. + * Quality comes from `productService.quality` (not user input) but we validate + * as defense-in-depth since these values end up in unquoted shell paths (the `~` + * prefix requires shell expansion, so we cannot single-quote the entire path). + */ +function validateShellToken(value: string, label: string): string { + if (!/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new Error(`Unsafe ${label} value for shell interpolation: ${JSON.stringify(value)}`); + } + return value; +} + +/** Install location for the VS Code CLI on the remote machine. */ +function getRemoteCLIDir(quality: string): string { + const q = validateShellToken(quality, 'quality'); + return q === 'stable' ? '~/.vscode-cli' : `~/.vscode-cli-${q}`; +} +function getRemoteCLIBin(quality: string): string { + const q = validateShellToken(quality, 'quality'); + const binaryName = q === 'stable' ? 'code' : 'code-insiders'; + return `${getRemoteCLIDir(q)}/${binaryName}`; +} + +/** Escape a string for use as a single shell argument (single-quote wrapping). */ +function shellEscape(s: string): string { + // Wrap in single quotes; escape embedded single quotes as: '\'' + const escaped = s.replace(/'/g, '\'\\\'\''); + return `'${escaped}'`; +} + +function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined { + const os = unameS.trim().toLowerCase(); + const machine = unameM.trim().toLowerCase(); + + let platformOs: string; + if (os === 'linux') { + platformOs = 'linux'; + } else if (os === 'darwin') { + platformOs = 'darwin'; + } else { + return undefined; + } + + let arch: string; + if (machine === 'x86_64' || machine === 'amd64') { + arch = 'x64'; + } else if (machine === 'aarch64' || machine === 'arm64') { + arch = 'arm64'; + } else if (machine === 'armv7l') { + arch = 'armhf'; + } else { + return undefined; + } + + return { os: platformOs, arch }; +} + +function buildCLIDownloadUrl(os: string, arch: string, quality: string): string { + return `https://update.code.visualstudio.com/latest/cli-${os}-${arch}/${quality}`; +} + +function sshExec(client: SSHClient, command: string, opts?: { ignoreExitCode?: boolean }): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { + client.exec(command, (err: Error | undefined, stream: SSHChannel) => { + if (err) { + reject(err); + return; + } + + let stdout = ''; + let stderr = ''; + let settled = false; + + const finish = (error: Error | undefined, code: number | undefined) => { + if (settled) { + return; + } + settled = true; + if (error) { + reject(error); + return; + } + if (code !== 0 && !opts?.ignoreExitCode) { + reject(new Error(`SSH command failed (exit ${code}): ${command}\nstderr: ${stderr}`)); + } else { + resolve({ stdout, stderr, code: code ?? 0 }); + } + }; + + stream.on('data', (data: Buffer) => { stdout += data.toString(); }); + stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + stream.on('error', (streamErr: Error) => finish(streamErr, undefined)); + stream.on('close', (code: number) => finish(undefined, code)); + }); + }); +} + +/** Redact connection tokens from log output. */ +function redactToken(text: string): string { + return text.replace(/\?tkn=[^\s&]+/g, '?tkn=***'); +} + +function startRemoteAgentHost( + client: SSHClient, + logService: ILogService, + quality: string, + commandOverride?: string, +): Promise<{ port: number; connectionToken: string | undefined; stream: SSHChannel }> { + return new Promise((resolve, reject) => { + const baseCmd = commandOverride ?? `${getRemoteCLIBin(quality)} agent-host --port 0 --accept-server-license-terms`; + // Wrap in a login shell so the agent host process inherits the + // user's PATH and environment from ~/.bash_profile / ~/.bashrc + // (ssh2 exec runs a non-interactive non-login shell by default). + const cmd = `bash -l -c ${shellEscape(baseCmd)}`; + logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`); + + client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => { + if (err) { + reject(err); + return; + } + + let resolved = false; + let outputBuf = ''; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error(`${LOG_PREFIX} Timed out waiting for agent host to start.\noutput so far: ${redactToken(outputBuf)}`)); + } + }, 60_000); + + const checkForAddress = () => { + if (!resolved) { + const match = outputBuf.match(/ws:\/\/127\.0\.0\.1:(\d+)(?:\?tkn=([^\s&]+))?/); + if (match) { + resolved = true; + clearTimeout(timeout); + const port = parseInt(match[1], 10); + const connectionToken = match[2] || undefined; + logService.info(`${LOG_PREFIX} Remote agent host listening on port ${port}`); + resolve({ port, connectionToken, stream }); + } + } + }; + + stream.stderr.on('data', (data: Buffer) => { + const text = data.toString(); + outputBuf += text; + logService.trace(`${LOG_PREFIX} remote stderr: ${redactToken(text.trimEnd())}`); + checkForAddress(); + }); + + stream.on('data', (data: Buffer) => { + const text = data.toString(); + outputBuf += text; + logService.trace(`${LOG_PREFIX} remote stdout: ${redactToken(text.trimEnd())}`); + checkForAddress(); + }); + + stream.on('close', (code: number) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + reject(new Error(`${LOG_PREFIX} Agent host process exited with code ${code} before becoming ready.\noutput: ${redactToken(outputBuf)}`)); + } + }); + }); + }); +} + +/** + * Create a WebSocket connection to the remote agent host via an SSH forwarded channel. + * Uses the `ws` library to speak WebSocket over the SSH channel. + * Messages are relayed to the renderer via IPC events. + */ +function createWebSocketRelay( + client: SSHClient, + dstHost: string, + dstPort: number, + connectionToken: string | undefined, + logService: ILogService, + onMessage: (data: string) => void, + onClose: () => void, +): Promise<{ send: (data: string) => void; close: () => void }> { + return new Promise((resolve, reject) => { + client.forwardOut('127.0.0.1', 0, dstHost, dstPort, (err: Error | undefined, channel: SSHChannel) => { + if (err) { + reject(err); + return; + } + + const WS = _require('ws') as typeof WebSocket; + let url = `ws://${dstHost}:${dstPort}`; + if (connectionToken) { + url += `?tkn=${encodeURIComponent(connectionToken)}`; + } + + // The SSH channel is a duplex stream compatible with ws's createConnection, + // but our minimal SSHChannel interface doesn't carry the full Node Duplex shape. + const ws = new WS(url, { createConnection: (() => channel) as unknown as WebSocket.ClientOptions['createConnection'] }); + + ws.on('open', () => { + logService.info(`${LOG_PREFIX} WebSocket relay connected to remote agent host`); + resolve({ + send: (data: string) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }, + close: () => ws.close(), + }); + }); + + ws.on('message', (data: WebSocket.RawData) => { + if (Array.isArray(data)) { + onMessage(Buffer.concat(data).toString()); + } else if (data instanceof ArrayBuffer) { + onMessage(Buffer.from(new Uint8Array(data)).toString()); + } else { + onMessage(data.toString()); + } + }); + + ws.on('close', onClose); + + ws.on('error', (wsErr: unknown) => { + logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`); + reject(wsErr); + }); + }); + }); +} + +function sanitizeConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfigSanitized { + const { password: _p, privateKeyPath: _k, ...sanitized } = config; + return sanitized; +} + +class SSHConnection extends Disposable { + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + readonly config: ISSHAgentHostConfigSanitized; + private _closed = false; + + constructor( + fullConfig: ISSHAgentHostConfig, + readonly connectionId: string, + readonly address: string, + readonly name: string, + readonly connectionToken: string | undefined, + sshClient: SSHClient, + private readonly _relay: { send: (data: string) => void; close: () => void }, + remoteStream: SSHChannel, + ) { + super(); + + this.config = sanitizeConfig(fullConfig); + + this._register(toDisposable(() => { + if (this._closed) { + return; + } + this._closed = true; + this._relay.close(); + remoteStream.close(); + sshClient.end(); + this._onDidClose.fire(); + })); + + sshClient.on('close', () => { + this.dispose(); + }); + + sshClient.on('error', () => { + this.dispose(); + }); + } + + relaySend(data: string): void { + this._relay.send(data); + } +} + +import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js'; + +export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRemoteAgentHostMainService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeConnections = this._register(new Emitter()); + readonly onDidChangeConnections: Event = this._onDidChangeConnections.event; + + private readonly _onDidCloseConnection = this._register(new Emitter()); + readonly onDidCloseConnection: Event = this._onDidCloseConnection.event; + + private readonly _onDidReportConnectProgress = this._register(new Emitter()); + readonly onDidReportConnectProgress: Event = this._onDidReportConnectProgress.event; + + private readonly _onDidRelayMessage = this._register(new Emitter()); + readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = this._register(new Emitter()); + readonly onDidRelayClose: Event = this._onDidRelayClose.event; + + private readonly _connections = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, + ) { + super(); + } + + async connect(config: ISSHAgentHostConfig): Promise { + const connectionKey = config.sshConfigHost + ? `ssh:${config.sshConfigHost}` + : `${config.username}@${config.host}:${config.port ?? 22}`; + + const existing = this._connections.get(connectionKey); + if (existing) { + return { + connectionId: existing.connectionId, + address: existing.address, + name: existing.name, + connectionToken: existing.connectionToken, + config: existing.config, + }; + } + + this._logService.info(`${LOG_PREFIX} Connecting to ${connectionKey}...`); + let sshClient: SSHClient | undefined; + + try { + const ssh2Module = _require('ssh2') as { Client: new () => unknown }; + + const reportProgress = (message: string) => { + this._onDidReportConnectProgress.fire({ connectionKey, message }); + }; + + // 1. Establish SSH connection + reportProgress(localize('sshProgressConnecting', "Establishing SSH connection...")); + sshClient = await this._connectSSH(config, ssh2Module.Client); + + if (config.remoteAgentHostCommand) { + // Dev override: skip platform detection and CLI install, + // use the provided command directly. + this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}`); + } else { + // 2. Detect remote platform + const { stdout: unameS } = await sshExec(sshClient, 'uname -s'); + const { stdout: unameM } = await sshExec(sshClient, 'uname -m'); + const platform = resolveRemotePlatform(unameS, unameM); + if (!platform) { + throw new Error(`${LOG_PREFIX} Unsupported remote platform: ${unameS.trim()} ${unameM.trim()}`); + } + this._logService.info(`${LOG_PREFIX} Remote platform: ${platform.os}-${platform.arch}`); + + // 3. Install CLI if needed + reportProgress(localize('sshProgressInstallingCLI', "Checking remote CLI installation...")); + await this._ensureCLIInstalled(sshClient, platform, reportProgress); + } + + // 4. Start agent-host and capture port/token + reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); + const { port: remotePort, connectionToken, stream: agentStream } = await startRemoteAgentHost(sshClient, this._logService, this._quality, config.remoteAgentHostCommand); + + // 5. Connect to remote agent host via WebSocket relay (no local TCP port) + reportProgress(localize('sshProgressForwarding', "Connecting to remote agent host...")); + const connectionId = connectionKey; + const relay = await createWebSocketRelay( + sshClient, '127.0.0.1', remotePort, connectionToken, this._logService, + (data: string) => this._onDidRelayMessage.fire({ connectionId, data }), + () => this._onDidRelayClose.fire(connectionId), + ); + + // 6. Create connection object + const address = connectionKey; + const conn = new SSHConnection( + config, + connectionId, + address, + config.name, + connectionToken, + sshClient, + relay, + agentStream, + ); + + conn.onDidClose(() => { + this._connections.delete(connectionKey); + this._onDidCloseConnection.fire(connectionId); + this._onDidChangeConnections.fire(); + }); + + this._connections.set(connectionKey, conn); + sshClient = undefined; // ownership transferred to SSHConnection + + this._onDidChangeConnections.fire(); + + return { + connectionId, + address, + name: config.name, + connectionToken, + config: conn.config, + sshConfigHost: config.sshConfigHost, + }; + + } catch (err) { + sshClient?.end(); + throw err; + } + } + + async disconnect(host: string): Promise { + for (const [key, conn] of this._connections) { + if (key === host || conn.connectionId === host) { + conn.dispose(); + return; + } + } + } + + async relaySend(connectionId: string, message: string): Promise { + for (const conn of this._connections.values()) { + if (conn.connectionId === connectionId) { + conn.relaySend(message); + return; + } + } + } + + async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise { + this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`); + const resolved = await this.resolveSSHConfig(sshConfigHost); + + let authMethod: SSHAuthMethod = SSHAuthMethod.Agent; + let privateKeyPath: string | undefined; + const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss']; + if (resolved.identityFile.length > 0 && !defaultKeys.includes(resolved.identityFile[0])) { + authMethod = SSHAuthMethod.KeyFile; + privateKeyPath = resolved.identityFile[0]; + } + + return this.connect({ + host: resolved.hostname, + port: resolved.port !== 22 ? resolved.port : undefined, + username: resolved.user ?? sshConfigHost, + authMethod, + privateKeyPath, + name, + sshConfigHost, + remoteAgentHostCommand, + }); + } + + async listSSHConfigHosts(): Promise { + const configPath = join(os.homedir(), '.ssh', 'config'); + try { + const content = await fsp.readFile(configPath, 'utf-8'); + return this._parseSSHConfigHosts(content, dirname(configPath)); + } catch { + this._logService.info(`${LOG_PREFIX} Could not read SSH config at ${configPath}`); + return []; + } + } + + async resolveSSHConfig(host: string): Promise { + return new Promise((resolve, reject) => { + cp.execFile('ssh', ['-G', host], { timeout: 5000 }, (err, stdout) => { + if (err) { + reject(new Error(`${LOG_PREFIX} ssh -G failed for ${host}: ${err.message}`)); + return; + } + const config = this._parseSSHGOutput(stdout); + resolve(config); + }); + }); + } + + private async _parseSSHConfigHosts(content: string, configDir: string, visited?: Set): Promise { + const seen = visited ?? new Set(); + const hosts: string[] = []; + + // Extract hosts from this file directly + hosts.push(...parseSSHConfigHostEntries(content)); + + // Follow Include directives + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const includeMatch = trimmed.match(/^Include\s+(.+)$/i); + if (!includeMatch) { + continue; + } + + const rawValue = stripSSHComment(includeMatch[1]); + const patterns = rawValue.split(/\s+/).filter(Boolean); + + for (const rawPattern of patterns) { + const pattern = rawPattern.replace(/^~/, os.homedir()); + const resolvedPattern = isAbsolute(pattern) ? pattern : join(configDir, pattern); + + if (seen.has(resolvedPattern)) { + continue; + } + seen.add(resolvedPattern); + + try { + const stat = await fsp.stat(resolvedPattern); + if (stat.isDirectory()) { + const files = await fsp.readdir(resolvedPattern); + for (const file of files) { + try { + const sub = await fsp.readFile(join(resolvedPattern, file), 'utf-8'); + hosts.push(...await this._parseSSHConfigHosts(sub, resolvedPattern, seen)); + } catch { /* skip unreadable files */ } + } + } else { + const sub = await fsp.readFile(resolvedPattern, 'utf-8'); + hosts.push(...await this._parseSSHConfigHosts(sub, dirname(resolvedPattern), seen)); + } + } catch { + const dir = dirname(resolvedPattern); + const base = basename(resolvedPattern); + if (base.includes('*')) { + try { + const files = await fsp.readdir(dir); + for (const file of files) { + const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$'); + if (regex.test(file)) { + try { + const sub = await fsp.readFile(join(dir, file), 'utf-8'); + hosts.push(...await this._parseSSHConfigHosts(sub, dir, seen)); + } catch { /* skip */ } + } + } + } catch { /* skip unreadable dirs */ } + } + } + } + } + return hosts; + } + + private _parseSSHGOutput(stdout: string): ISSHResolvedConfig { + return parseSSHGOutput(stdout); + } + + private async _connectSSH( + config: ISSHAgentHostConfig, + SSHClientCtor: new () => unknown, + ): Promise { + const connectConfig: Record = { + host: config.host, + port: config.port ?? 22, + username: config.username, + readyTimeout: 30_000, + keepaliveInterval: 15_000, + }; + + switch (config.authMethod) { + case SSHAuthMethod.Agent: { + const agentSock = process.env['SSH_AUTH_SOCK']; + this._logService.info(`${LOG_PREFIX} Using SSH agent: ${agentSock ?? '(not set)'}`); + connectConfig.agent = agentSock; + break; + } + case SSHAuthMethod.KeyFile: + if (config.privateKeyPath) { + const keyPath = config.privateKeyPath.replace(/^~/, os.homedir()); + connectConfig.privateKey = await fsp.readFile(keyPath); + } + break; + case SSHAuthMethod.Password: + connectConfig.password = config.password; + break; + } + + return new Promise((resolve, reject) => { + const client = new SSHClientCtor() as SSHClient; + + client.on('ready', () => { + this._logService.info(`${LOG_PREFIX} SSH connection established to ${config.host}`); + resolve(client); + }); + + client.on('error', (err: Error) => { + this._logService.error(`${LOG_PREFIX} SSH connection error: ${err.message}`); + reject(err); + }); + + client.connect(connectConfig); + }); + } + + private get _quality(): string { + return this._productService.quality || 'insider'; + } + + private async _ensureCLIInstalled(client: SSHClient, platform: { os: string; arch: string }, reportProgress: (message: string) => void): Promise { + const cliDir = getRemoteCLIDir(this._quality); + const cliBin = getRemoteCLIBin(this._quality); + const { code } = await sshExec(client, `${cliBin} --version`, { ignoreExitCode: true }); + if (code === 0) { + this._logService.info(`${LOG_PREFIX} VS Code CLI already installed on remote`); + return; + } + + reportProgress(localize('sshProgressDownloadingCLI', "Installing VS Code CLI on remote...")); + const url = buildCLIDownloadUrl(platform.os, platform.arch, this._quality); + + const installCmd = [ + `mkdir -p ${cliDir}`, + `curl -fsSL ${shellEscape(url)} | tar xz -C ${cliDir}`, + `chmod +x ${cliBin}`, + ].join(' && '); + + await sshExec(client, installCmd); + this._logService.info(`${LOG_PREFIX} VS Code CLI installed successfully`); + } + + override dispose(): void { + for (const conn of this._connections.values()) { + conn.dispose(); + } + this._connections.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/webSocketTransport.ts b/src/vs/platform/agentHost/node/webSocketTransport.ts new file mode 100644 index 0000000000000..42aef7bdfc79f --- /dev/null +++ b/src/vs/platform/agentHost/node/webSocketTransport.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import type * as wsTypes from 'ws'; +import type * as httpTypes from 'http'; +import type * as urlTypes from 'url'; + +/** + * 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: wsTypes.WebSocket, + private readonly _WebSocket: typeof wsTypes.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: wsTypes.WebSocketServer; + private readonly _httpServer: httpTypes.Server | undefined; + private readonly _WebSocket: typeof wsTypes.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 wsTypes, + http: typeof httpTypes, + url: typeof urlTypes, + ) { + 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: httpTypes.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..56aa36d402e5a --- /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 handler** in `protocolServerHandler.ts` request handler map. If the command requires I/O or agent interaction, it delegates to `IAgentService`. Implement the backing method in `AgentService` (or `AgentSideEffects` for operations that involve agent backends). +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/agentClientUri.test.ts b/src/vs/platform/agentHost/test/common/agentClientUri.test.ts new file mode 100644 index 0000000000000..f276a58a8dead --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentClientUri.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AGENT_CLIENT_SCHEME, fromAgentClientUri, toAgentClientUri } from '../../common/agentClientUri.js'; + +suite('Agent Client URI transform', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a file URI', () => { + const original = URI.file('/Users/user/plugins/my-plugin/index.js'); + const wrapped = toAgentClientUri(original, 'client-1'); + + assert.strictEqual(wrapped.scheme, AGENT_CLIENT_SCHEME); + assert.strictEqual(wrapped.authority, 'client-1'); + assert.ok(wrapped.path.startsWith('/file/')); + + const decoded = fromAgentClientUri(wrapped); + assert.strictEqual(decoded.scheme, 'file'); + assert.strictEqual(decoded.path, original.path); + }); + + test('round-trips a URI with authority', () => { + const original = URI.from({ scheme: 'https', authority: 'example.com', path: '/plugins/foo' }); + const wrapped = toAgentClientUri(original, 'c2'); + + const decoded = fromAgentClientUri(wrapped); + assert.strictEqual(decoded.scheme, 'https'); + assert.strictEqual(decoded.authority, 'example.com'); + assert.strictEqual(decoded.path, '/plugins/foo'); + }); + + test('encodes missing authority as dash', () => { + const original = URI.from({ scheme: 'inmemory', path: '/test/file.txt' }); + const wrapped = toAgentClientUri(original, 'c3'); + + assert.ok(wrapped.path.includes('/-/')); + + const decoded = fromAgentClientUri(wrapped); + assert.strictEqual(decoded.scheme, 'inmemory'); + assert.strictEqual(decoded.authority, ''); + assert.strictEqual(decoded.path, '/test/file.txt'); + }); + + test('preserves client ID as authority', () => { + const wrapped = toAgentClientUri(URI.file('/foo'), 'my-client'); + assert.strictEqual(wrapped.authority, 'my-client'); + }); +}); 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/common/sshConfigParsing.test.ts b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts new file mode 100644 index 0000000000000..857bd970398fe --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sshConfigParsing.test.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { parseSSHConfigHostEntries, parseSSHGOutput } from '../../common/sshConfigParsing.js'; + +suite('SSH Config Parsing', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseSSHConfigHostEntries', () => { + + test('extracts simple host entries', () => { + const config = [ + 'Host myserver', + ' HostName 10.0.0.1', + ' User admin', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('extracts multiple hosts from a single Host line', () => { + const config = 'Host server1 server2 server3'; + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['server1', 'server2', 'server3']); + }); + + test('extracts hosts from multiple Host directives', () => { + const config = [ + 'Host work', + ' HostName work.example.com', + '', + 'Host personal', + ' HostName home.example.com', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['work', 'personal']); + }); + + test('skips wildcard hosts', () => { + const config = [ + 'Host *', + ' ForwardAgent yes', + '', + 'Host myserver', + ' HostName 10.0.0.1', + '', + 'Host *.example.com', + ' User admin', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('skips negation patterns', () => { + const config = 'Host !internal myserver'; + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('skips question mark wildcards', () => { + const config = 'Host server? myserver'; + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('skips comment lines', () => { + const config = [ + '# This is a comment', + 'Host myserver', + ' # Another comment', + ' HostName 10.0.0.1', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('strips inline comments from Host values', () => { + const config = 'Host myserver # my favorite server'; + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + + test('handles empty content', () => { + assert.deepStrictEqual(parseSSHConfigHostEntries(''), []); + }); + + test('handles content with only comments and blanks', () => { + const config = [ + '# comment', + '', + ' # indented comment', + '', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), []); + }); + + test('is case-insensitive for Host keyword', () => { + const config = [ + 'host lower', + 'HOST upper', + 'Host mixed', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['lower', 'upper', 'mixed']); + }); + + test('ignores non-Host directives', () => { + const config = [ + 'Host myserver', + ' HostName 10.0.0.1', + ' User admin', + ' Port 2222', + ' IdentityFile ~/.ssh/mykey', + ' ForwardAgent yes', + ].join('\n'); + + assert.deepStrictEqual(parseSSHConfigHostEntries(config), ['myserver']); + }); + }); + + suite('parseSSHGOutput', () => { + + test('parses standard ssh -G output', () => { + const output = [ + 'hostname 10.0.0.1', + 'user admin', + 'port 22', + 'identityfile ~/.ssh/id_rsa', + 'identityfile ~/.ssh/id_ed25519', + 'forwardagent no', + ].join('\n'); + + assert.deepStrictEqual(parseSSHGOutput(output), { + hostname: '10.0.0.1', + user: 'admin', + port: 22, + identityFile: ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'], + forwardAgent: false, + }); + }); + + test('parses forwardagent yes', () => { + const output = [ + 'hostname example.com', + 'user root', + 'port 22', + 'forwardagent yes', + ].join('\n'); + + const result = parseSSHGOutput(output); + assert.strictEqual(result.forwardAgent, true); + }); + + test('parses non-standard port', () => { + const output = [ + 'hostname example.com', + 'user deploy', + 'port 2222', + ].join('\n'); + + const result = parseSSHGOutput(output); + assert.strictEqual(result.port, 2222); + }); + + test('handles missing user', () => { + const output = [ + 'hostname example.com', + 'port 22', + ].join('\n'); + + const result = parseSSHGOutput(output); + assert.strictEqual(result.user, undefined); + }); + + test('handles empty user', () => { + const output = [ + 'hostname example.com', + 'user ', + 'port 22', + ].join('\n'); + + const result = parseSSHGOutput(output); + assert.strictEqual(result.user, undefined); + }); + + test('defaults port to 22 when missing', () => { + const output = 'hostname example.com\nuser root'; + const result = parseSSHGOutput(output); + assert.strictEqual(result.port, 22); + }); + + test('collects multiple identity files', () => { + const output = [ + 'hostname example.com', + 'port 22', + 'identityfile ~/.ssh/id_rsa', + 'identityfile ~/.ssh/work_key', + 'identityfile ~/.ssh/id_ed25519', + ].join('\n'); + + assert.deepStrictEqual(parseSSHGOutput(output).identityFile, [ + '~/.ssh/id_rsa', + '~/.ssh/work_key', + '~/.ssh/id_ed25519', + ]); + }); + + test('handles empty output', () => { + assert.deepStrictEqual(parseSSHGOutput(''), { + hostname: '', + user: undefined, + port: 22, + identityFile: [], + forwardAgent: false, + }); + }); + + test('handles values with spaces', () => { + const output = 'hostname my host with spaces\nport 22'; + const result = parseSSHGOutput(output); + assert.strictEqual(result.hostname, 'my host with spaces'); + }); + }); +}); 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..de5c932de3dad --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -0,0 +1,437 @@ +/*--------------------------------------------------------------------------------------------- + * 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, RemoteAgentHostConnectionStatus, 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(); + + /** Wait for a connection to reach Connected status. */ + async function waitForConnected(): Promise { + while (!service.connections.some(c => c.status === RemoteAgentHostConnectionStatus.Connected)) { + await Event.toPromise(service.onDidChangeConnections); + } + } + + 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 () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + + // Resolve the connect promise + assert.strictEqual(createdClients.length, 1); + createdClients[0].connectDeferred.complete(); + await waitForConnected(); + + const connected = service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected); + assert.strictEqual(connected.length, 1); + assert.strictEqual(connected[0].address, 'host1:8080'); + assert.strictEqual(connected[0].name, 'Host 1'); + }); + + test('getConnection returns client after successful connect', async () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await waitForConnected(); + + 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 waitForConnected(); + + // 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 waitForConnected(); + + // Simulate connection close — entry transitions to Disconnected + const closedEvent = Event.toPromise(service.onDidChangeConnections); + createdClients[0].fireClose(); + await closedEvent; + + // Connection is still tracked (for reconnect) but getConnection returns undefined + assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); + const entry = service.connections.find(c => c.address === 'host1:8080'); + assert.ok(entry); + assert.strictEqual(entry.status, RemoteAgentHostConnectionStatus.Disconnected); + }); + + test('removes connection on connect failure', async () => { + configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]); + assert.strictEqual(createdClients.length, 1); + + // Fail the connection and wait for the service to react + const connectionChanged = Event.toPromise(service.onDidChangeConnections); + createdClients[0].connectDeferred.error(new Error('Connection refused')); + await connectionChanged; + + 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(); + createdClients[1].connectDeferred.complete(); + await waitForConnected(); + + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).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 waitForConnected(); + + 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 + const entry = service.connections.find(c => c.address === 'host1:8080'); + assert.strictEqual(entry?.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, + status: RemoteAgentHostConnectionStatus.Connected, + }); + }); + + test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => { + configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + createdClients[0].connectDeferred.complete(); + await waitForConnected(); + + 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, + status: RemoteAgentHostConnectionStatus.Connected, + }); + }); + + 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 waitForConnected(); + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).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 waitForConnected(); + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).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 waitForConnected(); + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).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(); + createdClients[1].connectDeferred.complete(); + await waitForConnected(); + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 2); + + await service.removeRemoteAgentHost('ws://host1:8080'); + + assert.deepStrictEqual(configService.entries, [ + { address: 'ws://host2:9090', name: 'Host 2' }, + ]); + assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).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 waitForConnected(); + + await service.removeRemoteAgentHost('ws://host1:8080'); + + assert.deepStrictEqual(configService.entries, []); + assert.strictEqual(service.connections.length, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.ts new file mode 100644 index 0000000000000..ca074d5c58965 --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/sshRelayTransport.test.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 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 { SSHRelayTransport } from '../../electron-browser/sshRelayTransport.js'; +import type { ISSHRelayMessage, ISSHRemoteAgentHostMainService, ISSHConnectProgress, ISSHConnectResult, ISSHAgentHostConfig, ISSHResolvedConfig } from '../../common/sshRemoteAgentHost.js'; + +/** + * Minimal mock of ISSHRemoteAgentHostMainService for testing the relay transport. + */ +class MockSSHMainService { + private readonly _onDidRelayMessage = new Emitter(); + readonly onDidRelayMessage = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = new Emitter(); + readonly onDidRelayClose = this._onDidRelayClose.event; + + readonly onDidChangeConnections = Event.None; + readonly onDidCloseConnection = Event.None; + readonly onDidReportConnectProgress = Event.None as Event; + + readonly sentMessages: { connectionId: string; message: string }[] = []; + + async relaySend(connectionId: string, message: string): Promise { + this.sentMessages.push({ connectionId, message }); + } + + async connect(_config: ISSHAgentHostConfig): Promise { + throw new Error('Not implemented'); + } + async disconnect(_host: string): Promise { } + async listSSHConfigHosts(): Promise { return []; } + async resolveSSHConfig(_host: string): Promise { + throw new Error('Not implemented'); + } + async reconnect(_sshConfigHost: string, _name: string): Promise { + throw new Error('Not implemented'); + } + + // Test helpers + fireRelayMessage(msg: ISSHRelayMessage): void { + this._onDidRelayMessage.fire(msg); + } + + fireRelayClose(connectionId: string): void { + this._onDidRelayClose.fire(connectionId); + } + + dispose(): void { + this._onDidRelayMessage.dispose(); + this._onDidRelayClose.dispose(); + } +} + +suite('SSHRelayTransport', () => { + + const disposables = new DisposableStore(); + let mockService: MockSSHMainService; + + setup(() => { + mockService = new MockSSHMainService(); + disposables.add({ dispose: () => mockService.dispose() }); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('receives messages matching connectionId', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"jsonrpc":"2.0","id":1}' }); + + assert.strictEqual(received.length, 1); + assert.deepStrictEqual(received[0], { jsonrpc: '2.0', id: 1 }); + }); + + test('ignores messages for other connectionIds', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockService.fireRelayMessage({ connectionId: 'conn-2', data: '{"jsonrpc":"2.0","id":1}' }); + + assert.strictEqual(received.length, 0); + }); + + test('drops malformed JSON messages', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + // Should not throw + mockService.fireRelayMessage({ connectionId: 'conn-1', data: 'not-json{{{' }); + + assert.strictEqual(received.length, 0); + }); + + test('fires onClose when relay closes for matching connectionId', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + let closed = false; + disposables.add(transport.onClose(() => { closed = true; })); + + mockService.fireRelayClose('conn-1'); + + assert.strictEqual(closed, true); + }); + + test('does not fire onClose for other connectionIds', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + let closed = false; + disposables.add(transport.onClose(() => { closed = true; })); + + mockService.fireRelayClose('conn-2'); + + assert.strictEqual(closed, false); + }); + + test('send() calls relaySend with correct connectionId', async () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const msg = { jsonrpc: '2.0' as const, method: 'test', id: 42 }; + transport.send(msg as never); + + // Give the async relaySend a tick to register + await new Promise(r => queueMicrotask(r)); + + assert.strictEqual(mockService.sentMessages.length, 1); + assert.strictEqual(mockService.sentMessages[0].connectionId, 'conn-1'); + assert.deepStrictEqual(JSON.parse(mockService.sentMessages[0].message), msg); + }); + + test('receives multiple messages in order', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const received: unknown[] = []; + disposables.add(transport.onMessage(msg => received.push(msg))); + + mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); + mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":2}' }); + mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":3}' }); + + assert.strictEqual(received.length, 3); + assert.deepStrictEqual(received, [{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + test('no events after dispose', () => { + const transport = disposables.add(new SSHRelayTransport('conn-1', mockService as unknown as ISSHRemoteAgentHostMainService)); + + const received: unknown[] = []; + let closed = false; + disposables.add(transport.onMessage(msg => received.push(msg))); + disposables.add(transport.onClose(() => { closed = true; })); + + transport.dispose(); + + mockService.fireRelayMessage({ connectionId: 'conn-1', data: '{"id":1}' }); + mockService.fireRelayClose('conn-1'); + + assert.strictEqual(received.length, 0); + assert.strictEqual(closed, false); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/AGENTS.md b/src/vs/platform/agentHost/test/node/AGENTS.md new file mode 100644 index 0000000000000..4ee8827208609 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/AGENTS.md @@ -0,0 +1,3 @@ +# Agent host unit tests + +For tests in this area that touch the SessionDatabase, they MUST use an in-memory database, not a real database file on disk. Use `SessionDatabase.open(':memory:')` and see the examples from existing tests. 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..8e092d8cec4ed --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -0,0 +1,315 @@ +/*--------------------------------------------------------------------------------------------- + * 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 with no prior deltas creates responsePart', () => { + const event: IAgentMessageEvent = { + session, + type: 'message', + role: 'assistant', + messageId: 'msg-1', + content: 'Some full message', + }; + + 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, 'Some full message'); + }); + + test('message event after deltas returns undefined', () => { + // First send a delta so the mapper tracks a current markdown part + const delta: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'hello' }; + mapper.mapProgressEventToActions(delta, session.toString(), turnId); + + const event: IAgentMessageEvent = { + session, + type: 'message', + role: 'assistant', + messageId: 'msg-1', + content: 'hello', + }; + + const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); + assert.strictEqual(result, undefined); + }); + + test('message event after tool_start creates responsePart for post-tool text', () => { + // Delta before tool call + const delta: IAgentDeltaEvent = { session, type: 'delta', messageId: 'msg-1', content: 'before' }; + mapper.mapProgressEventToActions(delta, session.toString(), turnId); + + // Tool call clears the current markdown part + const toolStart: IAgentToolStartEvent = { + session, type: 'tool_start', + toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', + invocationMessage: 'Running', toolInput: 'ls', + }; + mapper.mapProgressEventToActions(toolStart, session.toString(), turnId); + + // Message event with text that came after the tool call + const msg: IAgentMessageEvent = { + session, type: 'message', role: 'assistant', + messageId: 'msg-2', content: 'after tool', + }; + const actions = mapToArray(mapper.mapProgressEventToActions(msg, 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, 'after tool'); + }); + + test('message event with user role returns undefined', () => { + const event: IAgentMessageEvent = { + session, type: 'message', role: 'user', + messageId: 'msg-1', content: 'user text', + }; + const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); + assert.strictEqual(result, undefined); + }); + + test('message event with empty content returns undefined', () => { + const event: IAgentMessageEvent = { + session, type: 'message', role: 'assistant', + messageId: 'msg-1', content: '', + }; + const result = mapper.mapProgressEventToActions(event, session.toString(), turnId); + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts new file mode 100644 index 0000000000000..497d945e68430 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentPluginManager.test.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AGENT_CLIENT_SCHEME, toAgentClientUri } from '../../common/agentClientUri.js'; +import { CustomizationStatus, type ICustomizationRef, type ISessionCustomization } from '../../common/state/sessionState.js'; +import { AgentPluginManager } from '../../node/agentPluginManager.js'; + +suite('AgentPluginManager', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let manager: AgentPluginManager; + 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()))); + disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, disposables.add(new InMemoryFileSystemProvider()))); + manager = new AgentPluginManager(basePath, fileService, new NullLogService()); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + function pluginUri(name: string): string { + return URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }).toString(); + } + + function makeRef(name: string, nonce?: string): ICustomizationRef { + return { uri: pluginUri(name), displayName: `Plugin ${name}`, nonce }; + } + + async function seedPluginDir(name: string, files: Record): Promise { + const originalUri = URI.from({ scheme: Schemas.inMemory, path: `/plugins/${name}` }); + const agentClientDir = toAgentClientUri(originalUri, 'test-client'); + await fileService.createFolder(agentClientDir); + for (const [fileName, content] of Object.entries(files)) { + await fileService.writeFile(URI.joinPath(agentClientDir, fileName), VSBuffer.fromString(content)); + } + } + + // ---- syncCustomizations ------------------------------------------------- + + suite('syncCustomizations', () => { + + test('returns loaded status and pluginDir for each synced plugin', async () => { + await seedPluginDir('alpha', { 'index.js': 'a' }); + await seedPluginDir('beta', { 'index.js': 'b' }); + + const results = await manager.syncCustomizations('test-client', [ + makeRef('alpha', 'n1'), + makeRef('beta', 'n2'), + ]); + assert.strictEqual(results[0].customization.status, CustomizationStatus.Loaded); + assert.ok(results[0].pluginDir, 'should have pluginDir'); + assert.strictEqual(results[1].customization.status, CustomizationStatus.Loaded); + assert.ok(results[1].pluginDir, 'should have pluginDir'); + }); + + test('returns error status without pluginDir when source missing', async () => { + const results = await manager.syncCustomizations('test-client', [makeRef('nonexistent')]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].customization.status, CustomizationStatus.Error); + assert.ok(results[0].customization.statusMessage); + assert.strictEqual(results[0].pluginDir, undefined); + }); + + test('mixes loaded and error results', async () => { + await seedPluginDir('good', { 'index.js': 'ok' }); + + const results = await manager.syncCustomizations('test-client', [ + makeRef('good', 'n1'), + makeRef('missing'), + ]); + assert.strictEqual(results[1].customization.status, CustomizationStatus.Error); + assert.strictEqual(results[1].pluginDir, undefined); + }); + + test('fires progress callback with loading, then loaded', async () => { + await seedPluginDir('prog', { 'index.js': 'content' }); + + const progressCalls: ISessionCustomization[][] = []; + await manager.syncCustomizations('test-client', [makeRef('prog', 'n1')], statuses => { + progressCalls.push(statuses); + }); + + // At least two calls: initial loading + final loaded + assert.ok(progressCalls.length >= 2, `expected at least 2 progress calls, got ${progressCalls.length}`); + assert.strictEqual(progressCalls[0][0].status, CustomizationStatus.Loading); + assert.strictEqual(progressCalls[progressCalls.length - 1][0].status, CustomizationStatus.Loaded); + }); + + test('skips copy when nonce matches', async () => { + await seedPluginDir('cached', { 'index.js': 'v1' }); + const ref = makeRef('cached', 'nonce-abc'); + + const result1 = await manager.syncCustomizations('test-client', [ref]); + assert.ok(result1[0].pluginDir); + + // Second sync with same nonce should still succeed (from cache) + const result2 = await manager.syncCustomizations('test-client', [ref]); + assert.ok(result2[0].pluginDir); + assert.strictEqual(result1[0].pluginDir!.toString(), result2[0].pluginDir!.toString()); + }); + + test('serializes concurrent syncs of the same URI', async () => { + await seedPluginDir('concurrent', { 'index.js': 'v1' }); + const ref = makeRef('concurrent', 'n1'); + + // Fire two syncs concurrently + const [r1, r2] = await Promise.all([ + manager.syncCustomizations('test-client', [ref]), + manager.syncCustomizations('test-client', [ref]), + ]); + + // Both should succeed without error + assert.strictEqual(r1[0].customization.status, CustomizationStatus.Loaded); + assert.strictEqual(r2[0].customization.status, CustomizationStatus.Loaded); + }); + }); + + // ---- LRU eviction ------------------------------------------------------- + + suite('LRU eviction', () => { + + test('evicts least recently used plugins when limit exceeded', async () => { + const smallManager = new AgentPluginManager(basePath, fileService, new NullLogService(), 3); + + for (let i = 1; i <= 4; i++) { + await seedPluginDir(`plugin-${i}`, { 'index.js': `p${i}` }); + await smallManager.syncCustomizations('test-client', [makeRef(`plugin-${i}`, `n${i}`)]); + } + + // The evicted dir should no longer exist on disk (cache.json + 3 plugin dirs) + const evictedDir = URI.joinPath(basePath, 'agentPlugins'); + const listing = await fileService.resolve(evictedDir); + assert.ok(listing.children); + const pluginDirs = listing.children.filter(c => c.isDirectory); + assert.strictEqual(pluginDirs.length, 3, 'should have exactly 3 plugin dirs after eviction'); + }); + }); + + // ---- cache persistence -------------------------------------------------- + + suite('cache persistence', () => { + + test('restores nonce cache from disk on new manager instance', async () => { + await seedPluginDir('persist1', { 'index.js': 'v1' }); + const ref = makeRef('persist1', 'nonce-persist'); + + // Sync with first manager + await manager.syncCustomizations('test-client', [ref]); + + // Create a new manager pointing to the same base path + const manager2 = new AgentPluginManager(basePath, fileService, new NullLogService()); + const result = await manager2.syncCustomizations('test-client', [ref]); + + // Should be loaded from cache (nonce match), not error + assert.strictEqual(result[0].customization.status, CustomizationStatus.Loaded); + assert.ok(result[0].pluginDir); + }); + }); +}); 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..0fee52bc488f1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -0,0 +1,397 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IReference, toDisposable } 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 { NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { AgentSession } from '../../common/agentService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; +import { ResponsePartKind, SessionLifecycle, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type IMarkdownResponsePart, type IToolCallCompletedState, type IToolCallResponsePart } from '../../common/state/sessionState.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; + let fileService: FileService; + + setup(async () => { + const nullSessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: () => { throw new Error('not implemented'); }, + tryOpenDatabase: async () => undefined, + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + // Seed a directory for browseDirectory tests + await fileService.createFolder(URI.from({ scheme: Schemas.inMemory, path: '/testDir' })); + await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }), VSBuffer.fromString('hello')); + + service = disposables.add(new AgentService(new NullLogService(), fileService, 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('listSessions overlays custom title from session database', async () => { + // Pre-seed a custom title in an in-memory database + const db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('customTitle', 'My Custom Title'); + + const sessionId = 'test-session-abc'; + const sessionUri = AgentSession.uri('copilot', sessionId); + + const sessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: (): IReference => ({ + object: db, + dispose: () => { }, + }), + tryOpenDatabase: async (): Promise | undefined> => ({ + object: db, + dispose: () => { }, + }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; + + // Create a mock that returns a session with that ID + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.sessionMetadataOverrides = { summary: 'SDK Title' }; + // Manually add the session to the mock + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + + const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService)); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].summary, 'My Custom Title'); + }); + + test('listSessions uses SDK title when no custom title exists', async () => { + service.registerProvider(copilotAgent); + copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; + + await service.createSession({ provider: 'copilot' }); + + const sessions = await service.listSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].summary, 'Auto-generated Title'); + }); + + 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); + }); + }); + + // ---- restoreSession ------------------------------------------------- + + suite('restoreSession', () => { + + test('restores a session with message history', async () => { + service.registerProvider(copilotAgent); + const session = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi there!', toolRequests: [] }, + ]; + + await service.restoreSession(sessionResource); + + const state = service.stateManager.getSessionState(sessionResource.toString()); + 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); + assert.strictEqual(mdPart.content, 'Hi there!'); + assert.strictEqual(state!.turns[0].state, TurnState.Complete); + }); + + test('restores a session with tool calls', async () => { + service.registerProvider(copilotAgent); + const session = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.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 service.restoreSession(sessionResource); + + const state = service.stateManager.getSessionState(sessionResource.toString()); + assert.ok(state); + 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.confirmed, ToolCallConfirmationReason.NotNeeded); + }); + + test('flushes interrupted turns', async () => { + service.registerProvider(copilotAgent); + const session = await copilotAgent.createSession(); + const sessions = await copilotAgent.listSessions(); + const sessionResource = sessions[0].session; + + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Interrupted', toolRequests: [] }, + { type: 'message', session, role: 'user', messageId: 'msg-2', content: 'Retried', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-3', content: 'Answer', toolRequests: [] }, + ]; + + await service.restoreSession(sessionResource); + + const state = service.stateManager.getSessionState(sessionResource.toString()); + assert.ok(state); + assert.strictEqual(state!.turns.length, 2); + assert.strictEqual(state!.turns[0].state, TurnState.Cancelled); + assert.strictEqual(state!.turns[1].state, TurnState.Complete); + }); + + test('throws when session is not found on backend', async () => { + service.registerProvider(copilotAgent); + await assert.rejects( + () => service.restoreSession(AgentSession.uri('copilot', 'nonexistent')), + /Session not found on backend/, + ); + }); + }); + + // ---- resourceList ------------------------------------------------ + + suite('resourceList', () => { + + test('throws when the directory does not exist', async () => { + await assert.rejects( + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), + /Directory not found/, + ); + }); + + test('throws when the target is not a directory', async () => { + await assert.rejects( + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), + /Not a directory/, + ); + }); + }); +}); 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..5d8a27d3fd672 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -0,0 +1,872 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IReference, 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 { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { PendingMessageKind, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentService } from '../../node/agentService.js'; +import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { SessionDatabase } from '../../node/sessionDatabase.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(workingDirectory?: string): void { + stateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory, + }); + 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' }), + openDatabase: () => { throw new Error('not implemented'); }, + tryOpenDatabase: async () => undefined, + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + } satisfies ISessionDataService, + }, new NullLogService())); + }); + + 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())); + + 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); + }); + }); + + // ---- 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'); + }); + }); + + // ---- Pending message sync ----------------------------------------------- + + suite('pending message sync', () => { + + test('syncs steering message to agent on SessionPendingMessageSet', () => { + setupSession(); + + const action = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Steering, + id: 'steer-1', + userMessage: { text: 'focus on tests' }, + }; + stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(action); + + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', userMessage: { text: 'focus on tests' } }); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + }); + + test('syncs queued message to agent on SessionPendingMessageSet', () => { + setupSession(); + + const action = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-1', + userMessage: { text: 'queued message' }, + }; + stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(action); + + // Queued messages are not forwarded to the agent; the server controls consumption + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.strictEqual(agent.setPendingMessagesCalls[0].steeringMessage, undefined); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + + // Session was idle, so the queued message is consumed immediately + assert.strictEqual(agent.sendMessageCalls.length, 1); + assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message'); + }); + + test('syncs on SessionPendingMessageRemoved', () => { + setupSession(); + + // Add a queued message + const setAction = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-rm', + userMessage: { text: 'will be removed' }, + }; + stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(setAction); + + agent.setPendingMessagesCalls.length = 0; + + // Remove + const removeAction = { + type: ActionType.SessionPendingMessageRemoved as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-rm', + }; + stateManager.dispatchClientAction(removeAction, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(removeAction); + + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + }); + + test('syncs on SessionQueuedMessagesReordered', () => { + setupSession(); + + // Add two queued messages + const setA = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-a', userMessage: { text: 'A' } }; + stateManager.dispatchClientAction(setA, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(setA); + + const setB = { type: ActionType.SessionPendingMessageSet as const, session: sessionUri.toString(), kind: PendingMessageKind.Queued, id: 'q-b', userMessage: { text: 'B' } }; + stateManager.dispatchClientAction(setB, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(setB); + + agent.setPendingMessagesCalls.length = 0; + + // Reorder + const reorderAction = { type: ActionType.SessionQueuedMessagesReordered as const, session: sessionUri.toString(), order: ['q-b', 'q-a'] }; + stateManager.dispatchClientAction(reorderAction, { clientId: 'test', clientSeq: 3 }); + sideEffects.handleAction(reorderAction); + + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + }); + }); + + // ---- Queued message consumption ----------------------------------------- + + suite('queued message consumption', () => { + + test('auto-starts turn from queued message on idle', () => { + setupSession(); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Queue a message while a turn is active + startTurn('turn-1'); + const setAction = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-auto', + userMessage: { text: 'auto queued' }, + }; + stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(setAction); + + // Message should NOT be consumed yet (turn is active) + assert.strictEqual(agent.sendMessageCalls.length, 0); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + // Fire idle → turn completes → queued message should be consumed + agent.fireProgress({ session: sessionUri, type: 'idle' }); + + const turnComplete = envelopes.find(e => e.action.type === ActionType.SessionTurnComplete); + assert.ok(turnComplete, 'should dispatch session/turnComplete'); + + const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted); + assert.ok(turnStarted, 'should dispatch session/turnStarted for queued message'); + assert.strictEqual((turnStarted!.action as { queuedMessageId?: string }).queuedMessageId, 'q-auto'); + + assert.strictEqual(agent.sendMessageCalls.length, 1); + assert.strictEqual(agent.sendMessageCalls[0].prompt, 'auto queued'); + + // Queued message should be removed from state + const state = stateManager.getSessionState(sessionUri.toString()); + assert.strictEqual(state?.queuedMessages, undefined); + }); + + test('does not consume queued message while a turn is active', () => { + setupSession(); + startTurn('turn-1'); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + const setAction = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-wait', + userMessage: { text: 'should wait' }, + }; + stateManager.dispatchClientAction(setAction, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(setAction); + + // No turn started for the queued message + const turnStarted = envelopes.find(e => e.action.type === ActionType.SessionTurnStarted); + assert.strictEqual(turnStarted, undefined, 'should not start a turn while one is active'); + assert.strictEqual(agent.sendMessageCalls.length, 0); + + // Queued message still in state + const state = stateManager.getSessionState(sessionUri.toString()); + assert.strictEqual(state?.queuedMessages?.length, 1); + assert.strictEqual(state?.queuedMessages?.[0].id, 'q-wait'); + }); + + test('dispatches SessionPendingMessageRemoved for steering messages on steering_consumed', () => { + setupSession(); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + const action = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Steering, + id: 'steer-rm', + userMessage: { text: 'steer me' }, + }; + stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(action); + + // Removal is not dispatched synchronously; it waits for the agent + let removal = envelopes.find(e => + e.action.type === ActionType.SessionPendingMessageRemoved && + (e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering + ); + assert.strictEqual(removal, undefined, 'should not dispatch removal until steering_consumed'); + + // Simulate the agent consuming the steering message + agent.fireProgress({ + session: sessionUri, + type: 'steering_consumed', + id: 'steer-rm', + }); + + removal = envelopes.find(e => + e.action.type === ActionType.SessionPendingMessageRemoved && + (e.action as { kind: PendingMessageKind }).kind === PendingMessageKind.Steering + ); + assert.ok(removal, 'should dispatch SessionPendingMessageRemoved for steering'); + assert.strictEqual((removal!.action as { id: string }).id, 'steer-rm'); + + // Steering message should be removed from state + const state = stateManager.getSessionState(sessionUri.toString()); + assert.strictEqual(state?.steeringMessage, undefined); + }); + }); + + // ---- handleAction: session/activeClientChanged ---------------------- + + suite('handleAction — session/activeClientChanged', () => { + + test('calls setClientCustomizations and dispatches customizationsChanged', async () => { + setupSession(); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + const action: ISessionAction = { + type: ActionType.SessionActiveClientChanged, + session: sessionUri.toString(), + activeClient: { + clientId: 'test-client', + tools: [], + customizations: [ + { uri: 'file:///plugin-a', displayName: 'Plugin A' }, + { uri: 'file:///plugin-b', displayName: 'Plugin B' }, + ], + }, + }; + sideEffects.handleAction(action); + + // Wait for async setClientCustomizations + await new Promise(r => setTimeout(r, 50)); + + assert.deepStrictEqual(agent.setClientCustomizationsCalls, [{ + clientId: 'test-client', + customizations: [ + { uri: 'file:///plugin-a', displayName: 'Plugin A' }, + { uri: 'file:///plugin-b', displayName: 'Plugin B' }, + ], + }]); + + const customizationActions = envelopes + .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); + assert.ok(customizationActions.length >= 1, 'should dispatch at least one customizationsChanged'); + }); + + test('skips when activeClient has no customizations', () => { + setupSession(); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + const action: ISessionAction = { + type: ActionType.SessionActiveClientChanged, + session: sessionUri.toString(), + activeClient: { + clientId: 'test-client', + tools: [], + }, + }; + sideEffects.handleAction(action); + + assert.strictEqual(agent.setClientCustomizationsCalls.length, 0); + const customizationActions = envelopes + .filter(e => e.action.type === ActionType.SessionCustomizationsChanged); + assert.strictEqual(customizationActions.length, 0); + }); + + test('skips when activeClient is null', () => { + setupSession(); + + const action: ISessionAction = { + type: ActionType.SessionActiveClientChanged, + session: sessionUri.toString(), + activeClient: null, + }; + sideEffects.handleAction(action); + + assert.strictEqual(agent.setClientCustomizationsCalls.length, 0); + }); + }); + + // ---- handleAction: session/customizationToggled --------------------- + + suite('handleAction — session/customizationToggled', () => { + + test('calls setCustomizationEnabled on the agent', () => { + setupSession(); + + const action: ISessionAction = { + type: ActionType.SessionCustomizationToggled, + session: sessionUri.toString(), + uri: 'file:///plugin-a', + enabled: false, + }; + sideEffects.handleAction(action); + + assert.deepStrictEqual(agent.setCustomizationEnabledCalls, [ + { uri: 'file:///plugin-a', enabled: false }, + ]); + }); + }); + + // ---- handleAction: session/toolCallConfirmed ------------------------ + + suite('handleAction — session/toolCallConfirmed', () => { + + test('routes confirmation to correct agent via _toolCallAgents', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Fire tool_start to register the tool call + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-conf-1', + toolName: 'read', + displayName: 'Read File', + invocationMessage: 'Reading file', + }); + + // Fire tool_ready asking for permission (non-write, so not auto-approved) + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-conf-1', + invocationMessage: 'Read file.txt', + confirmationTitle: 'Read file.txt', + }); + + // Now confirm the tool call + sideEffects.handleAction({ + type: ActionType.SessionToolCallConfirmed, + session: sessionUri.toString(), + turnId: 'turn-1', + toolCallId: 'tc-conf-1', + approved: true, + confirmed: 'user-action' as const, + } as ISessionAction); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-conf-1', approved: true }, + ]); + }); + + test('handles denial of tool call', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-deny-1', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Running command', + }); + + sideEffects.handleAction({ + type: ActionType.SessionToolCallConfirmed, + session: sessionUri.toString(), + turnId: 'turn-1', + toolCallId: 'tc-deny-1', + approved: false, + reason: 'denied' as const, + } as ISessionAction); + + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-deny-1', approved: false }, + ]); + }); + }); + + // ---- Edit auto-approve ---------------------------------------------- + + suite('edit auto-approve', () => { + + test('auto-approves writes to regular source files', async () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-auto-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write file', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-auto-1', + invocationMessage: 'Write src/app.ts', + permissionKind: 'write', + permissionPath: '/workspace/src/app.ts', + }); + + // Auto-approved writes call respondToPermissionRequest directly + assert.deepStrictEqual(agent.respondToPermissionCalls, [ + { requestId: 'tc-auto-1', approved: true }, + ]); + }); + + test('blocks writes to .env files', () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + const envelopes: IActionEnvelope[] = []; + disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-env-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write .env', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-env-1', + invocationMessage: 'Write .env', + permissionKind: 'write', + permissionPath: '/workspace/.env', + confirmationTitle: 'Write .env', + }); + + // Should NOT auto-approve — .env is excluded + assert.strictEqual(agent.respondToPermissionCalls.length, 0); + + // Should dispatch a tool_ready action for the client to confirm + const readyAction = envelopes.find(e => e.action.type === ActionType.SessionToolCallReady); + assert.ok(readyAction, 'should dispatch tool_ready for blocked write'); + }); + + test('blocks writes to package.json', () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-pkg-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write package.json', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-pkg-1', + invocationMessage: 'Write package.json', + permissionKind: 'write', + permissionPath: '/workspace/package.json', + confirmationTitle: 'Write package.json', + }); + + assert.strictEqual(agent.respondToPermissionCalls.length, 0); + }); + + test('blocks writes to .lock files', () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-lock-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write yarn.lock', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-lock-1', + invocationMessage: 'Write yarn.lock', + permissionKind: 'write', + permissionPath: '/workspace/yarn.lock', + confirmationTitle: 'Write yarn.lock', + }); + + assert.strictEqual(agent.respondToPermissionCalls.length, 0); + }); + + test('blocks writes to .git directory', () => { + setupSession(URI.file('/workspace').toString()); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-git-1', + toolName: 'write', + displayName: 'Write', + invocationMessage: 'Write .git/config', + }); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-git-1', + invocationMessage: 'Write .git/config', + permissionKind: 'write', + permissionPath: '/workspace/.git/config', + confirmationTitle: 'Write .git/config', + }); + + assert.strictEqual(agent.respondToPermissionCalls.length, 0); + }); + }); + + // ---- Title persistence -------------------------------------------------- + + suite('title persistence', () => { + + let sessionDb: SessionDatabase; + + function createSessionDataServiceWithDb(): ISessionDataService { + return { + _serviceBrand: undefined, + getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }), + openDatabase: (): IReference => ({ + object: sessionDb, + dispose: () => { }, + }), + tryOpenDatabase: async (): Promise | undefined> => ({ + object: sessionDb, + dispose: () => { }, + }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; + } + + setup(async () => { + sessionDb = disposables.add(await SessionDatabase.open(':memory:')); + }); + + teardown(async () => { + await sessionDb.close(); + }); + + test('SessionTitleChanged persists to the database', async () => { + const sessionDataService = createSessionDataServiceWithDb(); + const localStateManager = disposables.add(new SessionStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localSideEffects = disposables.add(new AgentSideEffects(localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + }, new NullLogService())); + + localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }); + + localSideEffects.handleAction({ + type: ActionType.SessionTitleChanged, + session: sessionUri.toString(), + title: 'Custom Title', + }); + + // Wait for the async persistence + await new Promise(r => setTimeout(r, 50)); + + assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title'); + }); + + test('handleListSessions returns persisted custom title', async () => { + const sessionDataService = createSessionDataServiceWithDb(); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService)); + localService.registerProvider(localAgent); + + // Create a session on the agent backend + await localAgent.createSession(); + + // Persist a custom title in the DB + await sessionDb.setMetadata('customTitle', 'My Custom Title'); + + const sessions = await localService.listSessions(); + assert.strictEqual(sessions.length, 1); + // Custom title comes from the DB and is returned via the agent's listSessions + // The mock agent summary is used; the service doesn't read the DB for list + assert.ok(sessions[0].summary); + }); + + test('handleRestoreSession uses persisted custom title', async () => { + const sessionDataService = createSessionDataServiceWithDb(); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService)); + localService.registerProvider(localAgent); + + // Create a session on the agent backend + const session = await localAgent.createSession(); + const sessions = await localAgent.listSessions(); + const sessionResource = sessions[0].session; + + // Persist a custom title in the DB + await sessionDb.setMetadata('customTitle', 'Restored Title'); + + // Set up minimal messages for restore + localAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + assert.ok(state); + assert.strictEqual(state!.summary.title, 'Restored Title'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts b/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts new file mode 100644 index 0000000000000..c890ca7a60eb1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/commandAutoApprover.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../../../log/common/log.js'; +import { CommandAutoApprover } from '../../node/commandAutoApprover.js'; + +suite('CommandAutoApprover', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let approver: CommandAutoApprover; + + setup(() => { + approver = disposables.add(new CommandAutoApprover(new NullLogService())); + }); + + suite('shouldAutoApprove', () => { + + test('approves empty command', () => { + assert.strictEqual(approver.shouldAutoApprove(''), 'approved'); + assert.strictEqual(approver.shouldAutoApprove(' '), 'approved'); + }); + + // Safe readonly commands + test('approves allowed readonly commands', () => { + assert.strictEqual(approver.shouldAutoApprove('ls'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('ls -la'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('cat file.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('head -n 10 file.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('tail -f log.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('pwd'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('echo hello'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('grep -r pattern .'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('wc -l file.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('which node'), 'approved'); + }); + + // Dangerous commands + test('denies denied commands', () => { + assert.strictEqual(approver.shouldAutoApprove('rm file.txt'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('rm -rf /'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('rmdir folder'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('kill -9 1234'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('curl http://evil.com'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('wget http://evil.com'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('chmod 777 file'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('chown root file'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('eval "bad stuff"'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('xargs rm'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('dd if=/dev/zero of=/dev/sda'), 'denied'); + }); + + // Safe git sub-commands + test('approves allowed git sub-commands', () => { + assert.strictEqual(approver.shouldAutoApprove('git status'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('git log --oneline'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('git diff HEAD'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('git show HEAD'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('git ls-files'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('git branch'), 'approved'); + }); + + // Unsafe git sub-commands + test('denies denied git operations', () => { + assert.strictEqual(approver.shouldAutoApprove('git branch -D main'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('git branch --delete main'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('git log --output=/tmp/out'), 'denied'); + }); + + // Safe commands with dangerous arg blocking + test('handles find with blocked args', () => { + assert.strictEqual(approver.shouldAutoApprove('find . -name "*.ts"'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('find . -delete'), 'denied'); + // find -exec with ; is treated as a compound command, requiring confirmation + assert.strictEqual(approver.shouldAutoApprove('find . -exec rm {} ;'), 'noMatch'); + }); + + test('handles sed with blocked args', () => { + assert.strictEqual(approver.shouldAutoApprove('sed "s/foo/bar/g" file.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('sed -e "s/foo/bar/"'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('sed --expression "s/foo/bar/"'), 'denied'); + }); + + // npm/package managers + test('approves allowed npm commands', () => { + assert.strictEqual(approver.shouldAutoApprove('npm ci'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('npm ls'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('npm audit'), 'approved'); + }); + + // Unknown commands get noMatch + test('returns noMatch for unknown commands', () => { + assert.strictEqual(approver.shouldAutoApprove('my-custom-script'), 'noMatch'); + assert.strictEqual(approver.shouldAutoApprove('python script.py'), 'noMatch'); + assert.strictEqual(approver.shouldAutoApprove('node index.js'), 'noMatch'); + }); + + // Transient env vars + test('denies transient environment variable assignments', () => { + assert.strictEqual(approver.shouldAutoApprove('FOO=bar some-command'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('PATH=/evil:$PATH ls'), 'denied'); + }); + + // PowerShell + test('approves allowed PowerShell commands', () => { + assert.strictEqual(approver.shouldAutoApprove('Get-ChildItem'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('Get-Content file.txt'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('Write-Host "hello"'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('Select-Object Name'), 'approved'); + }); + + test('PowerShell case-insensitive rules work', () => { + // Rules with /i flag (like Select-*, Measure-*, etc.) are case-insensitive + assert.strictEqual(approver.shouldAutoApprove('select-object Name'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('SELECT-OBJECT Name'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('Measure-Command'), 'approved'); + assert.strictEqual(approver.shouldAutoApprove('measure-command'), 'approved'); + }); + + test('denies denied PowerShell commands', () => { + assert.strictEqual(approver.shouldAutoApprove('Remove-Item file.txt'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('Invoke-Expression "bad"'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('Invoke-WebRequest http://evil.com'), 'denied'); + assert.strictEqual(approver.shouldAutoApprove('Stop-Process -Id 1234'), 'denied'); + }); + + // Compound commands containing denied sub-commands should never be auto-approved, + // regardless of whether tree-sitter is available (with tree-sitter they are + // 'denied', without they are 'noMatch' — both are safe). + test('compound commands with denied sub-commands are not auto-approved', () => { + assert.notStrictEqual(approver.shouldAutoApprove('echo ok && rm -rf /'), 'approved'); + assert.notStrictEqual(approver.shouldAutoApprove('ls || curl evil.com'), 'approved'); + assert.notStrictEqual(approver.shouldAutoApprove('cat file; rm file'), 'approved'); + assert.notStrictEqual(approver.shouldAutoApprove('echo $(whoami)'), 'approved'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts new file mode 100644 index 0000000000000..d5577754a9b35 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotAgentForking.test.ts @@ -0,0 +1,705 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + parseEventLog, + serializeEventLog, + findTurnBoundaryInEventLog, + buildForkedEventLog, + buildTruncatedEventLog, + buildWorkspaceYaml, + forkSessionInDb, + truncateSessionInDb, + type ICopilotEventLogEntry, +} from '../../node/copilot/copilotAgentForking.js'; + +// ---- Test helpers ----------------------------------------------------------- + +function makeEntry(type: string, overrides?: Partial): ICopilotEventLogEntry { + return { + type, + data: {}, + id: `id-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + parentId: null, + ...overrides, + }; +} + +/** + * Builds a minimal event log representing a multi-turn session. + * Each turn = user.message → assistant.turn_start → assistant.message → assistant.turn_end. + */ +function buildTestEventLog(turnCount: number): ICopilotEventLogEntry[] { + const entries: ICopilotEventLogEntry[] = []; + let lastId: string | null = null; + + const sessionStart = makeEntry('session.start', { + id: 'session-start-id', + data: { sessionId: 'source-session', context: { cwd: '/test' } }, + parentId: null, + }); + entries.push(sessionStart); + lastId = sessionStart.id; + + for (let turn = 0; turn < turnCount; turn++) { + const userMsg = makeEntry('user.message', { + id: `user-msg-${turn}`, + data: { content: `Turn ${turn} message` }, + parentId: lastId, + }); + entries.push(userMsg); + lastId = userMsg.id; + + const turnStart = makeEntry('assistant.turn_start', { + id: `turn-start-${turn}`, + data: { turnId: String(turn) }, + parentId: lastId, + }); + entries.push(turnStart); + lastId = turnStart.id; + + const assistantMsg = makeEntry('assistant.message', { + id: `assistant-msg-${turn}`, + data: { content: `Response ${turn}` }, + parentId: lastId, + }); + entries.push(assistantMsg); + lastId = assistantMsg.id; + + const turnEnd = makeEntry('assistant.turn_end', { + id: `turn-end-${turn}`, + parentId: lastId, + }); + entries.push(turnEnd); + lastId = turnEnd.id; + } + + return entries; +} + +suite('CopilotAgentForking', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- parseEventLog / serializeEventLog ------------------------------ + + suite('parseEventLog', () => { + + test('parses a single-line JSONL', () => { + const entry = makeEntry('session.start'); + const jsonl = JSON.stringify(entry); + const result = parseEventLog(jsonl); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].type, 'session.start'); + }); + + test('parses multi-line JSONL', () => { + const entries = [makeEntry('session.start'), makeEntry('user.message')]; + const jsonl = entries.map(e => JSON.stringify(e)).join('\n'); + const result = parseEventLog(jsonl); + assert.strictEqual(result.length, 2); + }); + + test('ignores empty lines', () => { + const entry = makeEntry('session.start'); + const jsonl = '\n' + JSON.stringify(entry) + '\n\n'; + const result = parseEventLog(jsonl); + assert.strictEqual(result.length, 1); + }); + + test('empty input returns empty array', () => { + assert.deepStrictEqual(parseEventLog(''), []); + assert.deepStrictEqual(parseEventLog('\n\n'), []); + }); + }); + + suite('serializeEventLog', () => { + + test('round-trips correctly', () => { + const entries = buildTestEventLog(2); + const serialized = serializeEventLog(entries); + const parsed = parseEventLog(serialized); + assert.strictEqual(parsed.length, entries.length); + for (let i = 0; i < entries.length; i++) { + assert.strictEqual(parsed[i].id, entries[i].id); + assert.strictEqual(parsed[i].type, entries[i].type); + } + }); + + test('ends with a newline', () => { + const entries = [makeEntry('session.start')]; + const serialized = serializeEventLog(entries); + assert.ok(serialized.endsWith('\n')); + }); + }); + + // ---- findTurnBoundaryInEventLog ------------------------------------- + + suite('findTurnBoundaryInEventLog', () => { + + test('finds first turn boundary', () => { + const entries = buildTestEventLog(3); + const boundary = findTurnBoundaryInEventLog(entries, 0); + // Turn 0: user.message(1) + turn_start(2) + assistant.message(3) + turn_end(4) + // Index 4 = turn_end of turn 0 + assert.strictEqual(boundary, 4); + assert.strictEqual(entries[boundary].type, 'assistant.turn_end'); + assert.strictEqual(entries[boundary].id, 'turn-end-0'); + }); + + test('finds middle turn boundary', () => { + const entries = buildTestEventLog(3); + const boundary = findTurnBoundaryInEventLog(entries, 1); + // Turn 1 ends at index 8 + assert.strictEqual(boundary, 8); + assert.strictEqual(entries[boundary].type, 'assistant.turn_end'); + assert.strictEqual(entries[boundary].id, 'turn-end-1'); + }); + + test('finds last turn boundary', () => { + const entries = buildTestEventLog(3); + const boundary = findTurnBoundaryInEventLog(entries, 2); + assert.strictEqual(boundary, entries.length - 1); + assert.strictEqual(entries[boundary].type, 'assistant.turn_end'); + assert.strictEqual(entries[boundary].id, 'turn-end-2'); + }); + + test('returns -1 for non-existent turn', () => { + const entries = buildTestEventLog(2); + assert.strictEqual(findTurnBoundaryInEventLog(entries, 5), -1); + }); + + test('returns -1 for empty log', () => { + assert.strictEqual(findTurnBoundaryInEventLog([], 0), -1); + }); + }); + + // ---- buildForkedEventLog -------------------------------------------- + + suite('buildForkedEventLog', () => { + + test('forks at turn 0', () => { + const entries = buildTestEventLog(3); + const forked = buildForkedEventLog(entries, 0, 'new-session-id'); + + // Should have session.start + turn 0 events (user.message, turn_start, assistant.message, turn_end) + assert.strictEqual(forked.length, 5); + assert.strictEqual(forked[0].type, 'session.start'); + assert.strictEqual((forked[0].data as Record).sessionId, 'new-session-id'); + }); + + test('forks at turn 1', () => { + const entries = buildTestEventLog(3); + const forked = buildForkedEventLog(entries, 1, 'new-session-id'); + + // session.start + 2 turns × 4 events = 9 events + assert.strictEqual(forked.length, 9); + }); + + test('generates unique UUIDs', () => { + const entries = buildTestEventLog(2); + const forked = buildForkedEventLog(entries, 0, 'new-session-id'); + + const ids = new Set(forked.map(e => e.id)); + assert.strictEqual(ids.size, forked.length, 'All IDs should be unique'); + + // None should match the original + for (const entry of forked) { + assert.ok(!entries.some(e => e.id === entry.id), 'Should not reuse original IDs'); + } + }); + + test('re-chains parentId links', () => { + const entries = buildTestEventLog(2); + const forked = buildForkedEventLog(entries, 0, 'new-session-id'); + + // First event has no parent + assert.strictEqual(forked[0].parentId, null); + + // Each subsequent event's parentId should be a valid ID in the forked log + const idSet = new Set(forked.map(e => e.id)); + for (let i = 1; i < forked.length; i++) { + assert.ok( + forked[i].parentId !== null && idSet.has(forked[i].parentId!), + `Event ${i} (${forked[i].type}) should have a valid parentId`, + ); + } + }); + + test('strips session.shutdown and session.resume events', () => { + const entries = buildTestEventLog(2); + // Insert lifecycle events + entries.splice(5, 0, makeEntry('session.shutdown', { parentId: entries[4].id })); + entries.splice(6, 0, makeEntry('session.resume', { parentId: entries[5].id })); + + const forked = buildForkedEventLog(entries, 1, 'new-session-id'); + assert.ok(!forked.some(e => e.type === 'session.shutdown')); + assert.ok(!forked.some(e => e.type === 'session.resume')); + }); + + test('throws for invalid turn index', () => { + const entries = buildTestEventLog(1); + assert.throws(() => buildForkedEventLog(entries, 5, 'new-session-id')); + }); + + test('falls back to last kept event when lifecycle event parent is stripped', () => { + const entries = buildTestEventLog(2); + // Insert shutdown event between turns, then make the next turn's + // user.message point to the shutdown event (which will be stripped) + const shutdownEntry = makeEntry('session.shutdown', { + id: 'shutdown-1', + parentId: entries[4].id, // turn-end-0 + }); + entries.splice(5, 0, shutdownEntry); + // Next entry (user-msg-1) now points to the shutdown event + entries[6] = { ...entries[6], parentId: 'shutdown-1' }; + + const forked = buildForkedEventLog(entries, 1, 'new-session-id'); + + // All parentIds should be valid + const idSet = new Set(forked.map(e => e.id)); + for (let i = 1; i < forked.length; i++) { + assert.ok( + forked[i].parentId !== null && idSet.has(forked[i].parentId!), + `Event ${i} (${forked[i].type}) should have a valid parentId, got ${forked[i].parentId}`, + ); + } + }); + }); + + // ---- buildTruncatedEventLog ----------------------------------------- + + suite('buildTruncatedEventLog', () => { + + test('truncates to turn 0', () => { + const entries = buildTestEventLog(3); + const truncated = buildTruncatedEventLog(entries, 0); + + // New session.start + turn 0 events (user.message, turn_start, assistant.message, turn_end) + assert.strictEqual(truncated.length, 5); + assert.strictEqual(truncated[0].type, 'session.start'); + }); + + test('truncates to turn 1', () => { + const entries = buildTestEventLog(3); + const truncated = buildTruncatedEventLog(entries, 1); + + // New session.start + 2 turns × 4 events = 9 events + assert.strictEqual(truncated.length, 9); + }); + + test('prepends fresh session.start', () => { + const entries = buildTestEventLog(2); + const truncated = buildTruncatedEventLog(entries, 0); + + assert.strictEqual(truncated[0].type, 'session.start'); + assert.strictEqual(truncated[0].parentId, null); + // Should not reuse original session.start ID + assert.notStrictEqual(truncated[0].id, entries[0].id); + }); + + test('re-chains parentId links', () => { + const entries = buildTestEventLog(2); + const truncated = buildTruncatedEventLog(entries, 0); + + const idSet = new Set(truncated.map(e => e.id)); + for (let i = 1; i < truncated.length; i++) { + assert.ok( + truncated[i].parentId !== null && idSet.has(truncated[i].parentId!), + `Event ${i} (${truncated[i].type}) should have a valid parentId`, + ); + } + }); + + test('strips lifecycle events', () => { + const entries = buildTestEventLog(3); + // Add lifecycle events between turns + entries.splice(5, 0, makeEntry('session.shutdown')); + entries.splice(6, 0, makeEntry('session.resume')); + + const truncated = buildTruncatedEventLog(entries, 2); + const lifecycleEvents = truncated.filter( + e => e.type === 'session.shutdown' || e.type === 'session.resume', + ); + assert.strictEqual(lifecycleEvents.length, 0); + }); + + test('throws for invalid turn index', () => { + const entries = buildTestEventLog(1); + assert.throws(() => buildTruncatedEventLog(entries, 5)); + }); + + test('throws when no session.start exists', () => { + const entries = [makeEntry('user.message')]; + assert.throws(() => buildTruncatedEventLog(entries, 0)); + }); + }); + + // ---- buildWorkspaceYaml --------------------------------------------- + + suite('buildWorkspaceYaml', () => { + + test('contains required fields', () => { + const yaml = buildWorkspaceYaml('test-id', '/home/user/project', 'Test summary'); + assert.ok(yaml.includes('id: test-id')); + assert.ok(yaml.includes('cwd: /home/user/project')); + assert.ok(yaml.includes('summary: Test summary')); + assert.ok(yaml.includes('summary_count: 0')); + assert.ok(yaml.includes('created_at:')); + assert.ok(yaml.includes('updated_at:')); + }); + }); + + // ---- SQLite operations (in-memory) ---------------------------------- + + suite('forkSessionInDb', () => { + + async function openTestDb(): Promise { + const sqlite3 = await import('@vscode/sqlite3'); + return new Promise((resolve, reject) => { + const db = new sqlite3.default.Database(':memory:', (err: Error | null) => { + if (err) { + return reject(err); + } + resolve(db); + }); + }); + } + + function exec(db: import('@vscode/sqlite3').Database, sql: string): Promise { + return new Promise((resolve, reject) => { + db.exec(sql, err => err ? reject(err) : resolve()); + }); + } + + function all(db: import('@vscode/sqlite3').Database, sql: string, params: unknown[] = []): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, params, (err: Error | null, rows: Record[]) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); + } + + function close(db: import('@vscode/sqlite3').Database): Promise { + return new Promise((resolve, reject) => { + db.close(err => err ? reject(err) : resolve()); + }); + } + + async function setupSchema(db: import('@vscode/sqlite3').Database): Promise { + await exec(db, ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + cwd TEXT, + repository TEXT, + branch TEXT, + summary TEXT, + created_at TEXT, + updated_at TEXT, + host_type TEXT + ); + CREATE TABLE turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_index INTEGER NOT NULL, + user_message TEXT, + assistant_response TEXT, + timestamp TEXT, + UNIQUE(session_id, turn_index) + ); + CREATE TABLE session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + file_path TEXT, + tool_name TEXT, + turn_index INTEGER, + first_seen_at TEXT + ); + CREATE VIRTUAL TABLE search_index USING fts5( + content, + session_id, + source_type, + source_id + ); + CREATE TABLE checkpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + checkpoint_number INTEGER, + title TEXT, + overview TEXT, + history TEXT, + work_done TEXT, + technical_details TEXT, + important_files TEXT, + next_steps TEXT, + created_at TEXT + ); + `); + } + + async function seedTestData(db: import('@vscode/sqlite3').Database, sessionId: string, turnCount: number): Promise { + await exec(db, ` + INSERT INTO sessions (id, cwd, repository, branch, summary, created_at, updated_at, host_type) + VALUES ('${sessionId}', '/test', 'test-repo', 'main', 'Test session', '2026-01-01', '2026-01-01', 'github'); + `); + for (let i = 0; i < turnCount; i++) { + await exec(db, ` + INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp) + VALUES ('${sessionId}', ${i}, 'msg ${i}', 'resp ${i}', '2026-01-01'); + `); + await exec(db, ` + INSERT INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at) + VALUES ('${sessionId}', 'file${i}.ts', 'edit', ${i}, '2026-01-01'); + `); + } + } + + test('copies session metadata', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await seedTestData(db, 'source', 3); + + await forkSessionInDb(db, 'source', 'forked', 1); + + const sessions = await all(db, 'SELECT * FROM sessions WHERE id = ?', ['forked']); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].cwd, '/test'); + assert.strictEqual(sessions[0].repository, 'test-repo'); + } finally { + await close(db); + } + }); + + test('copies turns up to fork point', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await seedTestData(db, 'source', 3); + + await forkSessionInDb(db, 'source', 'forked', 1); + + const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index', ['forked']); + assert.strictEqual(turns.length, 2); // turns 0 and 1 + assert.strictEqual(turns[0].turn_index, 0); + assert.strictEqual(turns[1].turn_index, 1); + } finally { + await close(db); + } + }); + + test('copies session files up to fork point', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await seedTestData(db, 'source', 3); + + await forkSessionInDb(db, 'source', 'forked', 1); + + const files = await all(db, 'SELECT * FROM session_files WHERE session_id = ?', ['forked']); + assert.strictEqual(files.length, 2); // files from turns 0 and 1 + } finally { + await close(db); + } + }); + + test('does not affect source session', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await seedTestData(db, 'source', 3); + + await forkSessionInDb(db, 'source', 'forked', 1); + + const sourceTurns = await all(db, 'SELECT * FROM turns WHERE session_id = ?', ['source']); + assert.strictEqual(sourceTurns.length, 3); + } finally { + await close(db); + } + }); + }); + + suite('truncateSessionInDb', () => { + + async function openTestDb(): Promise { + const sqlite3 = await import('@vscode/sqlite3'); + return new Promise((resolve, reject) => { + const db = new sqlite3.default.Database(':memory:', (err: Error | null) => { + if (err) { + return reject(err); + } + resolve(db); + }); + }); + } + + function exec(db: import('@vscode/sqlite3').Database, sql: string): Promise { + return new Promise((resolve, reject) => { + db.exec(sql, err => err ? reject(err) : resolve()); + }); + } + + function all(db: import('@vscode/sqlite3').Database, sql: string, params: unknown[] = []): Promise[]> { + return new Promise((resolve, reject) => { + db.all(sql, params, (err: Error | null, rows: Record[]) => { + if (err) { + return reject(err); + } + resolve(rows); + }); + }); + } + + function close(db: import('@vscode/sqlite3').Database): Promise { + return new Promise((resolve, reject) => { + db.close(err => err ? reject(err) : resolve()); + }); + } + + async function setupSchema(db: import('@vscode/sqlite3').Database): Promise { + await exec(db, ` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + cwd TEXT, + repository TEXT, + branch TEXT, + summary TEXT, + created_at TEXT, + updated_at TEXT, + host_type TEXT + ); + CREATE TABLE turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + turn_index INTEGER NOT NULL, + user_message TEXT, + assistant_response TEXT, + timestamp TEXT, + UNIQUE(session_id, turn_index) + ); + CREATE VIRTUAL TABLE search_index USING fts5( + content, + session_id, + source_type, + source_id + ); + `); + } + + test('removes turns after truncation point', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await exec(db, ` + INSERT INTO sessions (id, cwd, summary, created_at, updated_at) + VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01'); + `); + for (let i = 0; i < 5; i++) { + await exec(db, ` + INSERT INTO turns (session_id, turn_index, user_message, timestamp) + VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01'); + `); + } + + await truncateSessionInDb(db, 'sess', 2); + + const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index', ['sess']); + assert.strictEqual(turns.length, 3); // turns 0, 1, 2 + assert.strictEqual(turns[0].turn_index, 0); + assert.strictEqual(turns[2].turn_index, 2); + } finally { + await close(db); + } + }); + + test('updates session timestamp', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await exec(db, ` + INSERT INTO sessions (id, cwd, summary, created_at, updated_at) + VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01'); + `); + await exec(db, ` + INSERT INTO turns (session_id, turn_index, user_message, timestamp) + VALUES ('sess', 0, 'msg 0', '2026-01-01'); + `); + + await truncateSessionInDb(db, 'sess', 0); + + const sessions = await all(db, 'SELECT updated_at FROM sessions WHERE id = ?', ['sess']); + assert.notStrictEqual(sessions[0].updated_at, '2026-01-01'); + } finally { + await close(db); + } + }); + + test('removes search index entries for truncated turns', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await exec(db, ` + INSERT INTO sessions (id, cwd, summary, created_at, updated_at) + VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01'); + `); + for (let i = 0; i < 3; i++) { + await exec(db, ` + INSERT INTO turns (session_id, turn_index, user_message, timestamp) + VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01'); + `); + await exec(db, ` + INSERT INTO search_index (content, session_id, source_type, source_id) + VALUES ('content ${i}', 'sess', 'turn', 'sess:turn:${i}'); + `); + } + + await truncateSessionInDb(db, 'sess', 0); + + const searchEntries = await all(db, 'SELECT * FROM search_index WHERE session_id = ?', ['sess']); + assert.strictEqual(searchEntries.length, 1); + assert.strictEqual(searchEntries[0].source_id, 'sess:turn:0'); + } finally { + await close(db); + } + }); + + test('removes all turns when keepUpToTurnIndex is -1', async () => { + const db = await openTestDb(); + try { + await setupSchema(db); + await exec(db, ` + INSERT INTO sessions (id, cwd, summary, created_at, updated_at) + VALUES ('sess', '/test', 'Test', '2026-01-01', '2026-01-01'); + `); + for (let i = 0; i < 3; i++) { + await exec(db, ` + INSERT INTO turns (session_id, turn_index, user_message, timestamp) + VALUES ('sess', ${i}, 'msg ${i}', '2026-01-01'); + `); + await exec(db, ` + INSERT INTO search_index (content, session_id, source_type, source_id) + VALUES ('content ${i}', 'sess', 'turn', 'sess:turn:${i}'); + `); + } + + await truncateSessionInDb(db, 'sess', -1); + + const turns = await all(db, 'SELECT * FROM turns WHERE session_id = ?', ['sess']); + assert.strictEqual(turns.length, 0, 'all turns should be removed'); + + const searchEntries = await all(db, 'SELECT * FROM search_index WHERE session_id = ?', ['sess']); + assert.strictEqual(searchEntries.length, 0, 'all search entries should be removed'); + } finally { + await close(db); + } + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts new file mode 100644 index 0000000000000..030fce812f2fc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -0,0 +1,365 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +import { Emitter } 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, ILogService } from '../../../log/common/log.js'; +import { IFileService } from '../../../files/common/files.js'; +import { AgentSession, IAgentProgressEvent } from '../../common/agentService.js'; +import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import { CopilotAgentSession, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; +import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; + +// ---- Mock CopilotSession (SDK level) ---------------------------------------- + +/** + * Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to + * store typed handlers, and exposes `fire()` so tests can push events + * through the real {@link CopilotSessionWrapper} event pipeline. + */ +class MockCopilotSession { + readonly sessionId = 'test-session-1'; + + private readonly _handlers = new Map void>>(); + + on(eventType: K, handler: TypedSessionEventHandler): () => void { + let set = this._handlers.get(eventType); + if (!set) { + set = new Set(); + this._handlers.set(eventType, set); + } + set.add(handler as (event: SessionEvent) => void); + return () => { set.delete(handler as (event: SessionEvent) => void); }; + } + + /** Push an event through to all registered handlers of the given type. */ + fire(type: K, data: SessionEventPayload['data']): void { + const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload; + const set = this._handlers.get(type); + if (set) { + for (const handler of set) { + handler(event); + } + } + } + + // Stubs for methods the wrapper / session class calls + async send() { return ''; } + async abort() { } + async setModel() { } + async getMessages() { return []; } + async destroy() { } +} + +// ---- Helpers ---------------------------------------------------------------- + +function createMockSessionDataService(): ISessionDataService { + const mockDb: ISessionDatabase = { + createTurn: async () => { }, + deleteTurn: async () => { }, + storeFileEdit: async () => { }, + getFileEdits: async () => [], + readFileEditContent: async () => undefined, + getMetadata: async () => undefined, + setMetadata: async () => { }, + close: async () => { }, + dispose: () => { }, + }; + return { + _serviceBrand: undefined, + getSessionDataDir: () => URI.from({ scheme: 'test', path: '/data' }), + getSessionDataDirById: () => URI.from({ scheme: 'test', path: '/data' }), + openDatabase: () => ({ object: mockDb, dispose: () => { } }), + tryOpenDatabase: async () => ({ object: mockDb, dispose: () => { } }), + deleteSessionData: async () => { }, + cleanupOrphanedData: async () => { }, + }; +} + +async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory?: URI }): Promise<{ + session: CopilotAgentSession; + mockSession: MockCopilotSession; + progressEvents: IAgentProgressEvent[]; +}> { + const progressEmitter = disposables.add(new Emitter()); + const progressEvents: IAgentProgressEvent[] = []; + disposables.add(progressEmitter.event(e => progressEvents.push(e))); + + const sessionUri = AgentSession.uri('copilot', 'test-session-1'); + const mockSession = new MockCopilotSession(); + + const factory: SessionWrapperFactory = async () => new CopilotSessionWrapper(mockSession as unknown as CopilotSession); + + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IFileService, { _serviceBrand: undefined } as IFileService); + services.set(ISessionDataService, createMockSessionDataService()); + const instantiationService = disposables.add(new InstantiationService(services)); + + const session = disposables.add(instantiationService.createInstance( + CopilotAgentSession, + sessionUri, + 'test-session-1', + options?.workingDirectory, + progressEmitter, + factory, + )); + + await session.initializeSession(); + + return { session, mockSession, progressEvents }; +} + +// ---- Tests ------------------------------------------------------------------ + +suite('CopilotAgentSession', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- permission handling ---- + + suite('permission handling', () => { + + test('auto-approves read inside working directory', async () => { + const { session } = await createAgentSession(disposables, { workingDirectory: URI.file('/workspace') }); + const result = await session.handlePermissionRequest({ + kind: 'read', + path: '/workspace/src/file.ts', + toolCallId: 'tc-1', + }); + assert.strictEqual(result.kind, 'approved'); + }); + + test('does not auto-approve read outside working directory', async () => { + const { session, progressEvents } = await createAgentSession(disposables, { workingDirectory: URI.file('/workspace') }); + + // Kick off permission request but don't await — it will block + const resultPromise = session.handlePermissionRequest({ + kind: 'read', + path: '/other/file.ts', + toolCallId: 'tc-2', + }); + + // Should have fired a tool_ready event + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'tool_ready'); + + // Respond to it + assert.ok(session.respondToPermissionRequest('tc-2', true)); + const result = await resultPromise; + assert.strictEqual(result.kind, 'approved'); + }); + + test('denies permission when no toolCallId', async () => { + const { session } = await createAgentSession(disposables); + const result = await session.handlePermissionRequest({ kind: 'write' }); + assert.strictEqual(result.kind, 'denied-interactively-by-user'); + }); + + test('denied-interactively when user denies', async () => { + const { session, progressEvents } = await createAgentSession(disposables); + const resultPromise = session.handlePermissionRequest({ + kind: 'shell', + toolCallId: 'tc-3', + }); + + assert.strictEqual(progressEvents.length, 1); + session.respondToPermissionRequest('tc-3', false); + const result = await resultPromise; + assert.strictEqual(result.kind, 'denied-interactively-by-user'); + }); + + test('pending permissions are denied on dispose', async () => { + const { session } = await createAgentSession(disposables); + const resultPromise = session.handlePermissionRequest({ + kind: 'write', + toolCallId: 'tc-4', + }); + + session.dispose(); + const result = await resultPromise; + assert.strictEqual(result.kind, 'denied-interactively-by-user'); + }); + + test('pending permissions are denied on abort', async () => { + const { session } = await createAgentSession(disposables); + const resultPromise = session.handlePermissionRequest({ + kind: 'write', + toolCallId: 'tc-5', + }); + + await session.abort(); + const result = await resultPromise; + assert.strictEqual(result.kind, 'denied-interactively-by-user'); + }); + + test('respondToPermissionRequest returns false for unknown id', async () => { + const { session } = await createAgentSession(disposables); + assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false); + }); + }); + + // ---- sendSteering ---- + + suite('sendSteering', () => { + + test('fires steering_consumed after send resolves', async () => { + const { session, progressEvents } = await createAgentSession(disposables); + + await session.sendSteering({ id: 'steer-1', userMessage: { text: 'focus on tests' } }); + + const consumed = progressEvents.find(e => e.type === 'steering_consumed'); + assert.ok(consumed, 'should fire steering_consumed event'); + assert.strictEqual((consumed as { id: string }).id, 'steer-1'); + }); + + test('does not fire steering_consumed when send fails', async () => { + const { session, mockSession, progressEvents } = await createAgentSession(disposables); + + mockSession.send = async () => { throw new Error('send failed'); }; + + await session.sendSteering({ id: 'steer-fail', userMessage: { text: 'will fail' } }); + + const consumed = progressEvents.find(e => e.type === 'steering_consumed'); + assert.strictEqual(consumed, undefined, 'should not fire steering_consumed on failure'); + }); + }); + + // ---- event mapping ---- + + suite('event mapping', () => { + + test('tool_start event is mapped for non-hidden tools', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-10', + toolName: 'bash', + arguments: { command: 'echo hello' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'tool_start'); + if (progressEvents[0].type === 'tool_start') { + assert.strictEqual(progressEvents[0].toolCallId, 'tc-10'); + assert.strictEqual(progressEvents[0].toolName, 'bash'); + } + }); + + test('hidden tools are not emitted as tool_start', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-11', + toolName: 'report_intent', + } as SessionEventPayload<'tool.execution_start'>['data']); + + assert.strictEqual(progressEvents.length, 0); + }); + + test('tool_complete event produces past-tense message', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + + // First fire tool_start so it's tracked + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-12', + toolName: 'bash', + arguments: { command: 'ls' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + // Then fire complete + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-12', + success: true, + result: { content: 'file1.ts\nfile2.ts' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + assert.strictEqual(progressEvents.length, 2); + assert.strictEqual(progressEvents[1].type, 'tool_complete'); + if (progressEvents[1].type === 'tool_complete') { + assert.strictEqual(progressEvents[1].toolCallId, 'tc-12'); + assert.ok(progressEvents[1].result.success); + assert.ok(progressEvents[1].result.pastTenseMessage); + } + }); + + test('tool_complete for untracked tool is ignored', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-untracked', + success: true, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + assert.strictEqual(progressEvents.length, 0); + }); + + test('idle event is forwarded', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'idle'); + }); + + test('error event is forwarded', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('session.error', { + errorType: 'TestError', + message: 'something went wrong', + stack: 'Error: something went wrong', + } as SessionEventPayload<'session.error'>['data']); + + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'error'); + if (progressEvents[0].type === 'error') { + assert.strictEqual(progressEvents[0].errorType, 'TestError'); + assert.strictEqual(progressEvents[0].message, 'something went wrong'); + } + }); + + test('message delta is forwarded', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('assistant.message_delta', { + messageId: 'msg-1', + deltaContent: 'Hello ', + } as SessionEventPayload<'assistant.message_delta'>['data']); + + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'delta'); + if (progressEvents[0].type === 'delta') { + assert.strictEqual(progressEvents[0].content, 'Hello '); + } + }); + + test('complete message with tool requests is forwarded', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('assistant.message', { + messageId: 'msg-2', + content: 'Let me help you.', + toolRequests: [{ + toolCallId: 'tc-20', + name: 'bash', + arguments: { command: 'ls' }, + type: 'function', + }], + } as SessionEventPayload<'assistant.message'>['data']); + + assert.strictEqual(progressEvents.length, 1); + assert.strictEqual(progressEvents[0].type, 'message'); + if (progressEvents[0].type === 'message') { + assert.strictEqual(progressEvents[0].content, 'Let me help you.'); + assert.strictEqual(progressEvents[0].toolRequests?.length, 1); + assert.strictEqual(progressEvents[0].toolRequests?.[0].toolCallId, 'tc-20'); + } + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts new file mode 100644 index 0000000000000..4a18ade2e3a61 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual } from '../../node/copilot/copilotPluginConverters.js'; +import type { IMcpServerDefinition, INamedPluginResource, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; + +suite('copilotPluginConverters', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + + setup(() => { + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- toSdkMcpServers ------------------------------------------------ + + suite('toSdkMcpServers', () => { + + test('converts local server definitions', () => { + const defs: IMcpServerDefinition[] = [{ + name: 'test-server', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL, + command: 'node', + args: ['server.js', '--port', '3000'], + env: { NODE_ENV: 'production', PORT: 3000 as unknown as string }, + cwd: '/workspace', + }, + }]; + + const result = toSdkMcpServers(defs); + assert.deepStrictEqual(result, { + 'test-server': { + type: 'local', + command: 'node', + args: ['server.js', '--port', '3000'], + tools: ['*'], + env: { NODE_ENV: 'production', PORT: '3000' }, + cwd: '/workspace', + }, + }); + }); + + test('converts remote/http server definitions', () => { + const defs: IMcpServerDefinition[] = [{ + name: 'remote-server', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.REMOTE, + url: 'https://example.com/mcp', + headers: { 'Authorization': 'Bearer token' }, + }, + }]; + + const result = toSdkMcpServers(defs); + assert.deepStrictEqual(result, { + 'remote-server': { + type: 'http', + url: 'https://example.com/mcp', + tools: ['*'], + headers: { 'Authorization': 'Bearer token' }, + }, + }); + }); + + test('handles empty definitions', () => { + const result = toSdkMcpServers([]); + assert.deepStrictEqual(result, {}); + }); + + test('omits optional fields when undefined', () => { + const defs: IMcpServerDefinition[] = [{ + name: 'minimal', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL, + command: 'echo', + }, + }]; + + const result = toSdkMcpServers(defs); + assert.strictEqual(result['minimal'].type, 'local'); + assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []); + assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false); + assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false); + }); + + test('filters null values from env', () => { + const defs: IMcpServerDefinition[] = [{ + name: 'with-null-env', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL, + command: 'test', + env: { KEEP: 'value', DROP: null as unknown as string }, + }, + }]; + + const result = toSdkMcpServers(defs); + const env = (result['with-null-env'] as { env?: Record }).env; + assert.deepStrictEqual(env, { KEEP: 'value' }); + }); + }); + + // ---- toSdkCustomAgents ---------------------------------------------- + + suite('toSdkCustomAgents', () => { + + test('reads agent files and creates configs', async () => { + const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' }); + await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant')); + + const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }]; + const result = await toSdkCustomAgents(agents, fileService); + + assert.deepStrictEqual(result, [{ + name: 'helper', + prompt: 'You are a helpful assistant', + }]); + }); + + test('skips agents whose files cannot be read', async () => { + const agents: INamedPluginResource[] = [ + { uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' }, + ]; + const result = await toSdkCustomAgents(agents, fileService); + assert.deepStrictEqual(result, []); + }); + + test('processes multiple agents, skipping failures', async () => { + const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' }); + await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent')); + + const agents: INamedPluginResource[] = [ + { uri: goodUri, name: 'good' }, + { uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' }, + ]; + const result = await toSdkCustomAgents(agents, fileService); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'good'); + }); + }); + + // ---- toSdkSkillDirectories ------------------------------------------ + + suite('toSdkSkillDirectories', () => { + + test('extracts parent directories of skill URIs', () => { + const skills: INamedPluginResource[] = [ + { uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' }, + { uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' }, + ]; + const result = toSdkSkillDirectories(skills); + assert.strictEqual(result.length, 2); + }); + + test('deduplicates directories', () => { + const skills: INamedPluginResource[] = [ + { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' }, + { uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' }, + ]; + const result = toSdkSkillDirectories(skills); + assert.strictEqual(result.length, 1); + }); + + test('handles empty input', () => { + const result = toSdkSkillDirectories([]); + assert.deepStrictEqual(result, []); + }); + }); + + // ---- parsedPluginsEqual --------------------------------------------- + + suite('parsedPluginsEqual', () => { + + function makePlugin(overrides?: Partial): IParsedPlugin { + return { + hooks: [], + mcpServers: [], + skills: [], + agents: [], + ...overrides, + }; + } + + test('returns true for identical empty plugins', () => { + assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true); + }); + + test('returns true for same content', () => { + const a = makePlugin({ + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + mcpServers: [{ + name: 'server', + uri: URI.file('/mcp'), + configuration: { type: McpServerType.LOCAL, command: 'node' }, + }], + }); + const b = makePlugin({ + skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }], + mcpServers: [{ + name: 'server', + uri: URI.file('/mcp'), + configuration: { type: McpServerType.LOCAL, command: 'node' }, + }], + }); + assert.strictEqual(parsedPluginsEqual([a], [b]), true); + }); + + test('returns false for different content', () => { + const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] }); + const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] }); + assert.strictEqual(parsedPluginsEqual([a], [b]), false); + }); + + test('returns false for different lengths', () => { + assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false); + }); + + test('returns true for empty arrays', () => { + assert.strictEqual(parsedPluginsEqual([], []), true); + }); + }); +}); 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..8433372ab0f8d --- /dev/null +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ToolResultContentType } from '../../common/state/sessionState.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { FileEditTracker, buildSessionDbUri, parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; + +suite('FileEditTracker', () => { + + const disposables = new DisposableStore(); + let fileService: FileService; + let db: SessionDatabase; + let tracker: FileEditTracker; + + setup(async () => { + fileService = disposables.add(new FileService(new NullLogService())); + const sourceFs = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider('file', sourceFs)); + + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + + tracker = new FileEditTracker('copilot:/test-session', db, fileService, new NullLogService()); + }); + + teardown(async () => { + disposables.clear(); + await db.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks edit start and complete for existing file', async () => { + 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 = await tracker.takeCompletedEdit('turn-1', 'tc-1', '/workspace/test.txt'); + assert.ok(fileEdit); + assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); + + // URIs are parseable session-db: URIs + const beforeFields = parseSessionDbUri(fileEdit.before!.content.uri); + assert.ok(beforeFields); + assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session'); + assert.strictEqual(beforeFields.toolCallId, 'tc-1'); + assert.strictEqual(beforeFields.filePath, '/workspace/test.txt'); + assert.strictEqual(beforeFields.part, 'before'); + + const afterFields = parseSessionDbUri(fileEdit.after!.content.uri); + assert.ok(afterFields); + assert.strictEqual(afterFields.part, 'after'); + + // Content is persisted in the database (wait for fire-and-forget write) + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-1', '/workspace/test.txt'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original content\nline 2'); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified content\nline 2\nline 3'); + }); + + test('tracks edit for newly created file (no before content)', async () => { + 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 = await tracker.takeCompletedEdit('turn-1', 'tc-2', '/workspace/new-file.txt'); + assert.ok(fileEdit); + + // Wait for the fire-and-forget DB write to complete + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-2', '/workspace/new-file.txt'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), ''); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'new file\ncontent'); + }); + + test('takeCompletedEdit returns undefined for unknown file path', async () => { + const result = await tracker.takeCompletedEdit('turn-1', 'tc-x', '/nonexistent'); + assert.strictEqual(result, undefined); + }); + + test('before and after content can be read from database', async () => { + 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'); + + await tracker.takeCompletedEdit('turn-1', 'tc-3', '/workspace/file.ts'); + + // Wait for the fire-and-forget DB write to complete + await new Promise(r => setTimeout(r, 50)); + + const content = await db.readFileEditContent('tc-3', '/workspace/file.ts'); + assert.ok(content); + assert.strictEqual(new TextDecoder().decode(content.beforeContent), 'original'); + assert.strictEqual(new TextDecoder().decode(content.afterContent), 'modified'); + }); +}); + +suite('buildSessionDbUri / parseSessionDbUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round-trips a simple URI', () => { + const uri = buildSessionDbUri('copilot:/abc-123', 'tc-1', '/workspace/file.ts', 'before'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.deepStrictEqual(parsed, { + sessionUri: 'copilot:/abc-123', + toolCallId: 'tc-1', + filePath: '/workspace/file.ts', + part: 'before', + }); + }); + + test('round-trips with special characters in filePath', () => { + const uri = buildSessionDbUri('copilot:/s1', 'tc-2', '/work space/file (1).ts', 'after'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.strictEqual(parsed.filePath, '/work space/file (1).ts'); + assert.strictEqual(parsed.part, 'after'); + }); + + test('round-trips with special characters in toolCallId', () => { + const uri = buildSessionDbUri('copilot:/s1', 'call_abc=123&x', '/file.ts', 'before'); + const parsed = parseSessionDbUri(uri); + assert.ok(parsed); + assert.strictEqual(parsed.toolCallId, 'call_abc=123&x'); + }); + + test('parseSessionDbUri returns undefined for non-session-db URIs', () => { + assert.strictEqual(parseSessionDbUri('file:///foo/bar'), undefined); + assert.strictEqual(parseSessionDbUri('https://example.com'), undefined); + }); + + test('parseSessionDbUri returns undefined for malformed session-db URIs', () => { + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1'), undefined); + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1'), undefined); + assert.strictEqual(parseSessionDbUri('session-db:copilot:/s1?toolCallId=tc-1&filePath=/f&part=middle'), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts new file mode 100644 index 0000000000000..3472a3019a717 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.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 } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentSession } from '../../common/agentService.js'; +import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; +import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; +import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; + +suite('mapSessionEvents', () => { + + const disposables = new DisposableStore(); + let db: SessionDatabase | undefined; + const session = AgentSession.uri('copilot', 'test-session'); + + teardown(async () => { + disposables.clear(); + await db?.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Basic event mapping -------------------------------------------- + + test('maps user and assistant messages', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { messageId: 'msg-1', content: 'hello' } }, + { type: 'assistant.message', data: { messageId: 'msg-2', content: 'world' } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result[0], { + session, + type: 'message', + role: 'user', + messageId: 'msg-1', + content: 'hello', + toolRequests: undefined, + reasoningOpaque: undefined, + reasoningText: undefined, + encryptedContent: undefined, + parentToolCallId: undefined, + }); + assert.strictEqual(result[1].type, 'message'); + assert.strictEqual((result[1] as { role: string }).role, 'assistant'); + }); + + test('maps tool start and complete events', async () => { + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'echo hi' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'hi\n' } }, + }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].type, 'tool_start'); + assert.strictEqual(result[1].type, 'tool_complete'); + + const complete = result[1] as { result: { content?: readonly { type: string; text?: string }[] } }; + assert.ok(complete.result.content); + assert.strictEqual(complete.result.content[0].type, ToolResultContentType.Text); + }); + + test('skips tool_complete without matching tool_start', async () => { + const events: ISessionEvent[] = [ + { type: 'tool.execution_complete', data: { toolCallId: 'orphan', success: true } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 0); + }); + + test('ignores unknown event types', async () => { + const events: ISessionEvent[] = [ + { type: 'some.unknown.event', data: {} }, + { type: 'user.message', data: { messageId: 'msg-1', content: 'test' } }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + assert.strictEqual(result.length, 1); + }); + + // ---- File edit restoration ------------------------------------------ + + suite('file edit restoration', () => { + + test('restores file edits from database for edit tools', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-edit', + filePath: '/workspace/file.ts', + kind: FileEditKind.Edit, + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: 3, + removedLines: 1, + }); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-edit', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-edit', success: true, result: { content: 'Edited file.ts' } }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const complete = result[1]; + assert.strictEqual(complete.type, 'tool_complete'); + + const content = (complete as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Should have text content + file edit + assert.strictEqual(content.length, 2); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + assert.strictEqual(content[1].type, ToolResultContentType.FileEdit); + + // File edit URIs should be parseable + const fileEdit = content[1] as { before: { uri: any; content: { uri: any } }; after: { uri: any; content: { uri: any } }; diff?: { added?: number; removed?: number } }; + const beforeFields = parseSessionDbUri(fileEdit.before.content.uri); + assert.ok(beforeFields); + assert.strictEqual(beforeFields.toolCallId, 'tc-edit'); + assert.strictEqual(beforeFields.filePath, '/workspace/file.ts'); + assert.strictEqual(beforeFields.part, 'before'); + assert.deepStrictEqual(fileEdit.diff, { added: 3, removed: 1 }); + }); + + test('handles multiple file edits for one tool call', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-multi', + filePath: '/workspace/a.ts', + kind: FileEditKind.Edit, + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('a'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-multi', + filePath: '/workspace/b.ts', + kind: FileEditKind.Edit, + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('b'), + addedLines: undefined, + removedLines: undefined, + }); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-multi', toolName: 'write' }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-multi', success: true }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Two file edits (no text since result had no content) + const fileEdits = content.filter(c => c.type === ToolResultContentType.FileEdit); + assert.strictEqual(fileEdits.length, 2); + }); + + test('works without database (no file edits restored)', async () => { + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { filePath: '/workspace/file.ts' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'done' } }, + }, + ]; + + const result = await mapSessionEvents(session, undefined, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + // Only text content, no file edits + assert.strictEqual(content.length, 1); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + }); + + test('non-edit tools do not get file edits even if db has data', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + const events: ISessionEvent[] = [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'shell', arguments: { command: 'ls' } }, + }, + { + type: 'tool.execution_complete', + data: { toolCallId: 'tc-1', success: true, result: { content: 'files' } }, + }, + ]; + + const result = await mapSessionEvents(session, db, events); + const content = (result[1] as { result: { content?: readonly Record[] } }).result.content; + assert.ok(content); + assert.strictEqual(content.length, 1); + assert.strictEqual(content[0].type, ToolResultContentType.Text); + }); + }); +}); 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..8bb73997c9b67 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -0,0 +1,456 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; +import { URI } from '../../../../base/common/uri.js'; +import { type ISyncedCustomization } from '../../common/agentPluginManager.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 { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; + +/** Well-known auto-generated title used by the 'with-title' prompt. */ +export const MOCK_AUTO_TITLE = 'Automatically generated title'; + +/** + * 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 setPendingMessagesCalls: { session: URI; steeringMessage: IPendingMessage | undefined; queuedMessages: readonly IPendingMessage[] }[] = []; + readonly disposeSessionCalls: URI[] = []; + readonly abortSessionCalls: URI[] = []; + readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; + readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; + readonly setClientCustomizationsCalls: { clientId: string; customizations: ICustomizationRef[] }[] = []; + readonly setCustomizationEnabledCalls: { uri: string; enabled: boolean }[] = []; + + /** Configurable return value for getCustomizations. */ + customizations: ICustomizationRef[] = []; + + /** 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 }); + } + + setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void { + this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); + } + + 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; + } + + getCustomizations(): ICustomizationRef[] { + return this.customizations; + } + + async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { + this.setClientCustomizationsCalls.push({ clientId, customizations }); + const results: ISyncedCustomization[] = customizations.map(c => ({ + customization: { + customization: c, + enabled: true, + status: CustomizationStatus.Loaded, + }, + })); + progress?.(results); + return results; + } + + setCustomizationEnabled(uri: string, enabled: boolean): void { + this.setCustomizationEnabledCalls.push({ uri, enabled }); + } + + 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', + }; + (async () => { + await timeout(10); + this._onDidSessionProgress.fire(toolStartEvent); + await timeout(5); + this._onDidSessionProgress.fire(toolReadyEvent); + })(); + 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 'write-file': { + // Fire tool_start + tool_ready with write permission for a regular file (should be auto-approved) + (async () => { + await timeout(10); + this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-write-1', toolName: 'write', displayName: 'Write File', invocationMessage: 'Write file' }); + await timeout(5); + this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-write-1', invocationMessage: 'Write src/app.ts', permissionKind: 'write', permissionPath: '/workspace/src/app.ts' }); + // Auto-approved writes resolve immediately — complete the tool and turn + await timeout(10); + this._fireSequence(session, [ + { type: 'tool_complete', session, toolCallId: 'tc-write-1', result: { pastTenseMessage: 'Wrote file', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true } }, + { type: 'idle', session }, + ]); + })(); + break; + } + + case 'write-env': { + // Fire tool_start + tool_ready with write permission for .env (should be blocked) + (async () => { + await timeout(10); + this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-write-env-1', toolName: 'write', displayName: 'Write File', invocationMessage: 'Write file' }); + await timeout(5); + this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-write-env-1', invocationMessage: 'Write .env', permissionKind: 'write', permissionPath: '/workspace/.env', confirmationTitle: 'Write .env' }); + })(); + this._pendingPermissions.set('tc-write-env-1', (approved) => { + if (approved) { + this._fireSequence(session, [ + { type: 'tool_complete', session, toolCallId: 'tc-write-env-1', result: { pastTenseMessage: 'Wrote .env', content: [{ type: ToolResultContentType.Text, text: 'ok' }], success: true } }, + { type: 'idle', session }, + ]); + } + }); + break; + } + + case 'run-safe-command': { + // Fire tool_start + tool_ready with shell permission for an allowed command (should be auto-approved) + (async () => { + await timeout(10); + this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); + await timeout(5); + this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-1', invocationMessage: 'ls -la', permissionKind: 'shell', toolInput: 'ls -la' }); + // Auto-approved shell commands resolve immediately + await timeout(10); + this._fireSequence(session, [ + { type: 'tool_complete', session, toolCallId: 'tc-shell-1', result: { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: 'file1.ts\nfile2.ts' }], success: true } }, + { type: 'idle', session }, + ]); + })(); + break; + } + + case 'run-dangerous-command': { + // Fire tool_start + tool_ready with shell permission for a denied command (should require confirmation) + (async () => { + await timeout(10); + this._onDidSessionProgress.fire({ type: 'tool_start', session, toolCallId: 'tc-shell-deny-1', toolName: 'bash', displayName: 'Run Command', invocationMessage: 'Run command' }); + await timeout(5); + this._onDidSessionProgress.fire({ type: 'tool_ready', session, toolCallId: 'tc-shell-deny-1', invocationMessage: 'rm -rf /', permissionKind: 'shell', toolInput: 'rm -rf /', confirmationTitle: 'Run in terminal' }); + })(); + this._pendingPermissions.set('tc-shell-deny-1', (approved) => { + if (approved) { + this._fireSequence(session, [ + { type: 'tool_complete', session, toolCallId: 'tc-shell-deny-1', result: { pastTenseMessage: 'Ran command', content: [{ type: ToolResultContentType.Text, text: '' }], success: true } }, + { 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 'with-reasoning': + this._fireSequence(session, [ + { type: 'reasoning', session, content: 'Let me think' }, + { type: 'reasoning', session, content: ' about this...' }, + { type: 'delta', session, messageId: 'msg-1', content: 'Reasoned response.' }, + { type: 'idle', session }, + ]); + break; + + case 'with-title': + this._fireSequence(session, [ + { type: 'delta', session, messageId: 'msg-1', content: 'Title response.' }, + { type: 'title_changed', session, title: MOCK_AUTO_TITLE }, + { 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; + } + } + + setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void { + // When steering is set, consume it on the next tick + if (steeringMessage) { + timeout(20).then(() => { + this._onDidSessionProgress.fire({ type: 'steering_consumed', session, id: steeringMessage.id }); + }); + } + } + + async setClientCustomizations() { + return []; + } + + setCustomizationEnabled() { + + } + + 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 + } + + async truncateSession(_session: URI, _turnIndex?: number): Promise { + // Mock agent accepts truncation without side effects + } + + async forkSession(_sourceSession: URI, newSessionId: string, _turnIndex: number): Promise { + // Create the forked session so it can be resumed + const session = AgentSession.uri('mock', newSessionId); + this._sessions.set(newSessionId, session); + } + + 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/extensions/github-authentication/extension.webpack.config.js b/src/vs/platform/agentHost/test/node/pluginStorage.test.ts similarity index 65% rename from extensions/github-authentication/extension.webpack.config.js rename to src/vs/platform/agentHost/test/node/pluginStorage.test.ts index 166c1d8b1e340..02fedc68357b7 100644 --- a/extensions/github-authentication/extension.webpack.config.js +++ b/src/vs/platform/agentHost/test/node/pluginStorage.test.ts @@ -2,12 +2,6 @@ * 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', - }, -}); +// Plugin storage logic has been folded into AgentPluginManager. +// See agentPluginManager.test.ts for tests. 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..64a77cd26b53c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -0,0 +1,514 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js'; +import { IResourceReadResult } 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 IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, 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 } from '../../node/protocolServerHandler.js'; +import { SessionStateManager } from '../../node/sessionStateManager.js'; +import { AgentHostFileSystemProvider } from '../../common/agentHostFileSystemProvider.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 MockAgentService implements IAgentService { + declare readonly _serviceBrand: undefined; + readonly handledActions: ISessionAction[] = []; + readonly browsedUris: URI[] = []; + readonly browseErrors = new Map(); + + private readonly _onDidAction = new Emitter(); + readonly onDidAction = this._onDidAction.event; + private readonly _onDidNotification = new Emitter(); + readonly onDidNotification = this._onDidNotification.event; + + private _stateManager!: SessionStateManager; + + /** Connect to the state manager so dispatchAction works correctly. */ + setStateManager(sm: SessionStateManager): void { + this._stateManager = sm; + } + + dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this.handledActions.push(action); + const origin = { clientId, clientSeq }; + this._stateManager.dispatchClientAction(action, origin); + } + async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///new-session'); } + async disposeSession(_session: URI): Promise { } + async listSessions(): Promise { return []; } + async subscribe(resource: URI): Promise { + 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 { } + async shutdown(): Promise { } + async getResourceMetadata(): Promise { return { resources: [] }; } + async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } + async refreshModels(): Promise { } + async listAgents(): Promise { return []; } + async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } + async resourceList(uri: URI): Promise { + this.browsedUris.push(uri); + const error = this.browseErrors.get(uri.toString()); + if (error) { + throw error; + } + return { + entries: [ + { name: 'src', type: 'directory' }, + { name: 'README.md', type: 'file' }, + ], + }; + } + async resourceRead(_uri: URI): Promise { + throw new Error('Not implemented'); + } + async resourceCopy(): Promise<{}> { return {}; } + async resourceDelete(): Promise<{}> { return {}; } + async resourceMove(): Promise<{}> { return {}; } + + dispose(): void { + this._onDidAction.dispose(); + this._onDidNotification.dispose(); + } +} + +// ---- 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 agentService: MockAgentService; + 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()); + agentService = new MockAgentService(); + agentService.setStateManager(stateManager); + disposables.add(agentService); + disposables.add(handler = new ProtocolServerHandler( + agentService, + stateManager, + server, + { defaultDirectory: URI.file('/home/testuser').toString() }, + disposables.add(new AgentHostFileSystemProvider()), + 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('resourceList 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, 'resourceList', { uri: dirUri })); + const resp = await responsePromise; + + assert.strictEqual(agentService.browsedUris.length, 1); + assert.strictEqual(agentService.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('resourceList 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(); + agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'resourceList', { 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 authenticate to throw a ProtocolError with data + const origHandler = agentService.authenticate; + agentService.authenticate = 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' }); + + agentService.authenticate = 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..83306c71f0e0c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts @@ -0,0 +1,1444 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ISubscribeResult } from '../../common/state/protocol/commands.js'; +import type { IActionEnvelope, IResponsePartAction, ISessionAddedNotification, ISessionRemovedNotification, ITitleChangedAction, 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 { PendingMessageKind, ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; +import { MOCK_AUTO_TITLE, 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, workingDirectory?: string): Promise { + await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); + + await c.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory }); + + 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'); + }); + + // ---- Multi-client tests ----------------------------------------------------- + + test('sessionAdded notification is broadcast to all connected clients', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-1' }); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-2' }); + + client.clearReceived(); + client2.clearReceived(); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const n1 = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const n2 = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.ok(n1, 'client 1 should receive sessionAdded'); + assert.ok(n2, 'client 2 should receive sessionAdded'); + + const uri1 = ((n1.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + const uri2 = ((n2.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + assert.strictEqual(uri1, uri2, 'both clients should see the same session URI'); + + client2.close(); + }); + + test('sessionRemoved notification is broadcast to all connected clients', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-broadcast-remove-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-remove-2' }); + client2.clearReceived(); + + await client.call('disposeSession', { session: sessionUri }); + + const n1 = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + const n2 = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + assert.ok(n1, 'client 1 should receive sessionRemoved'); + assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing'); + + const removed1 = (n1.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + const removed2 = (n2.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + assert.strictEqual(removed1.session.toString(), sessionUri.toString()); + assert.strictEqual(removed2.session.toString(), sessionUri.toString()); + + client2.close(); + }); + + test('client B sends message on session created by client A', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cross-msg-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-msg-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + // Client B dispatches the turn + dispatchTurnStarted(client2, sessionUri, 'turn-cross', 'hello', 1); + + const r1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const r2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + assert.ok(r1, 'client A should see responsePart from client B turn'); + assert.ok(r2, 'client B should see its own responsePart'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('both clients receive full tool progress updates', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-tool-progress-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-tool-progress-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-tool-mc', 'use-tool', 1); + + // Both clients should see the full tool lifecycle + for (const c of [client, client2]) { + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await c.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await c.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + } + + client2.close(); + }); + + test('unsubscribed client receives no actions but still gets notifications', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-scoping-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-scoping-2' }); + // Client 2 does NOT subscribe to the session + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-scoped', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Give some time for any stray actions to arrive + await new Promise(resolve => setTimeout(resolve, 300)); + const sessionActions = client2.receivedNotifications(n => n.method === 'action'); + assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should receive no session actions'); + + // But disposing the session should still broadcast a notification + client2.clearReceived(); + await client.call('disposeSession', { session: sessionUri }); + + const removed = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + assert.ok(removed, 'unsubscribed client should still receive sessionRemoved notification'); + + client2.close(); + }); + + test('late subscriber gets current state via snapshot', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-late-sub'); + dispatchTurnStarted(client, sessionUri, 'turn-late', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Client 2 joins after the turn has completed + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' }); + + const result = await client2.call('subscribe', { resource: sessionUri }); + const state = result.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`); + assert.strictEqual(state.turns[0].id, 'turn-late'); + assert.strictEqual(state.turns[0].state, 'complete'); + + client2.close(); + }); + + test('permission flow: client B confirms tool started by client A', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cross-perm-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-perm-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + // Client A starts the permission turn + dispatchTurnStarted(client, sessionUri, 'turn-cross-perm', 'permission', 1); + + // Both clients should see tool_start and tool_ready + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Client B confirms the tool call + client2.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-cross-perm', + toolCallId: 'tc-perm-1', + approved: true, + }, + }); + + // Both clients should see the response and turn completion + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + 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(); + }); + + // ---- Edit auto-approve patterns ----------------------------------------- + + test('auto-approves write to regular file (no pending confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace'); + client.clearReceived(); + + // Start a turn that triggers a write permission request for a regular .ts file + dispatchTurnStarted(client, sessionUri, 'turn-autoapprove', 'write-file', 1); + + // The write should be auto-approved — we should see tool_start, tool_complete, and turn_complete + // but NOT a pending-confirmation toolCallReady (one without `confirmed`). + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify no pending-confirmation toolCallReady was received + const pendingConfirmNotifs = client.receivedNotifications(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const action = getActionEnvelope(n).action as { confirmed?: string }; + return !action.confirmed; + }); + assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for auto-approved write'); + }); + + test('blocks write to .env file (requires manual confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace'); + client.clearReceived(); + + // Start a turn that tries to write .env (blocked by default patterns) + dispatchTurnStarted(client, sessionUri, 'turn-deny', 'write-env', 1); + + // The .env write should NOT be auto-approved — we should see toolCallReady (pending confirmation) + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm it manually to let the turn complete + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-deny', + toolCallId: 'tc-write-env-1', + approved: true, + confirmed: 'user-action', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Session rename / title -------------------------------------------------- + + test('client titleChanged updates session state snapshot', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/titleChanged', + session: sessionUri, + title: 'My Custom Title', + }, + }); + + const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; + assert.strictEqual(titleAction.title, 'My Custom Title'); + + // Verify the snapshot reflects the new title + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.title, 'My Custom Title'); + }); + + test('agent-generated titleChanged is broadcast', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-agent-title'); + dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1); + + const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; + assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify the snapshot reflects the auto-generated title + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); + }); + + test('renamed session title persists across listSessions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-title-list'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/titleChanged', + session: sessionUri, + title: 'Persisted Title', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + + // Poll listSessions until the persisted title appears (async DB write) + let session: { title: string } | undefined; + for (let i = 0; i < 20; i++) { + const result = await client.call('listSessions'); + session = result.items.find(s => s.resource === sessionUri); + if (session?.title === 'Persisted Title') { + break; + } + await timeout(100); + } + assert.ok(session, 'session should appear in listSessions'); + assert.strictEqual(session.title, 'Persisted Title'); + }); + + // ---- Reasoning events ------------------------------------------------------- + + test('reasoning events produce reasoning response parts and append actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-reasoning'); + dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1); + + // The first reasoning event produces a responsePart with kind Reasoning + const reasoningPart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/responsePart')) { + return false; + } + const action = getActionEnvelope(n).action as IResponsePartAction; + return action.part.kind === ResponsePartKind.Reasoning; + }); + const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction; + assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning); + + // The second reasoning chunk produces a session/reasoning append action + const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning')); + const appendAction = getActionEnvelope(appendNotif).action; + assert.strictEqual(appendAction.type, 'session/reasoning'); + if (appendAction.type === 'session/reasoning') { + assert.strictEqual(appendAction.content, ' about this...'); + } + + // Then the markdown response part + const mdPart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/responsePart')) { + return false; + } + const action = getActionEnvelope(n).action as IResponsePartAction; + return action.part.kind === ResponsePartKind.Markdown; + }); + assert.ok(mdPart); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Queued messages -------------------------------------------------------- + + test('queued message is auto-consumed when session is idle', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle'); + client.clearReceived(); + + // Queue a message when the session is idle — server should immediately consume it + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Queued, + id: 'q-1', + userMessage: { text: 'hello' }, + }, + }); + + // The server should auto-consume the queued message and start a turn + await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted')); + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify the turn was created from the queued message + 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].userMessage.text, 'hello'); + // Queue should be empty after consumption + assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption'); + }); + + test('queued message waits for in-progress turn to complete', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait'); + + // Start a turn first + dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1); + + // Wait for the first turn's response to confirm it is in progress + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + + // Queue a message while the turn is in progress + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Queued, + id: 'q-wait-1', + userMessage: { text: 'hello' }, + }, + }); + + // First turn should complete + const firstComplete = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/turnComplete')) { + return false; + } + return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first'; + }); + const firstSeq = getActionEnvelope(firstComplete).serverSeq; + + // The queued message's turn should complete AFTER the first turn + const secondComplete = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/turnComplete')) { + return false; + } + const envelope = getActionEnvelope(n); + return (envelope.action as { turnId: string }).turnId !== 'turn-first' + && envelope.serverSeq > firstSeq; + }); + assert.ok(secondComplete, 'should receive a second turnComplete from the queued message'); + + 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}`); + }); + + // ---- Steering messages ------------------------------------------------------ + + test('steering message is set and consumed by agent', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-steering'); + + // Start a turn first + dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1); + + // Set a steering message while the turn is in progress + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Steering, + id: 'steer-1', + userMessage: { text: 'Please be concise' }, + }, + }); + + // The steering message should be set in state initially + const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet')); + assert.ok(setNotif, 'should see pendingMessageSet action'); + + // The mock agent consumes steering and fires steering_consumed, + // which causes the server to dispatch pendingMessageRemoved + const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved')); + assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Steering should be cleared from state + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption'); + }); + + // ---- Shell auto-approve ------------------------------------------------- + + test('auto-approves allowed shell command (no pending confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-shell-approve'); + client.clearReceived(); + + // Start a turn that triggers a shell permission request for "ls -la" (allowed command) + dispatchTurnStarted(client, sessionUri, 'turn-shell-approve', 'run-safe-command', 1); + + // The shell command should be auto-approved — we should see tool_start, tool_complete, and turn_complete + // but NOT a pending-confirmation toolCallReady. + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify no pending-confirmation toolCallReady was received + const pendingConfirmNotifs = client.receivedNotifications(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const action = getActionEnvelope(n).action as { confirmed?: string }; + return !action.confirmed; + }); + assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for allowed shell command'); + }); + + test('blocks denied shell command (requires manual confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-shell-deny'); + client.clearReceived(); + + // Start a turn that triggers a shell permission request for "rm -rf /" (denied command) + dispatchTurnStarted(client, sessionUri, 'turn-shell-deny', 'run-dangerous-command', 1); + + // The denied command should NOT be auto-approved — we should see toolCallReady (pending confirmation) + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm it manually to let the turn complete + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-shell-deny', + toolCallId: 'tc-shell-deny-1', + approved: true, + confirmed: 'user-action', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Truncation tests --------------------------------------------------- + + test('truncate session removes turns after specified turn', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate'); + + // Create two turns + dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2'); + + // Verify 2 turns exist + let snapshot = await client.call('subscribe', { resource: sessionUri }); + let state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 2); + + client.clearReceived(); + + // Truncate: keep only turn-t1 + client.notify('dispatchAction', { + clientSeq: 3, + action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + snapshot = await client.call('subscribe', { resource: sessionUri }); + state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 1); + assert.strictEqual(state.turns[0].id, 'turn-t1'); + }); + + test('truncate all turns clears session history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all'); + + dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client.clearReceived(); + + // Truncate all (no turnId) + client.notify('dispatchAction', { + clientSeq: 2, + action: { type: 'session/truncated', session: sessionUri }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 0); + }); + + test('new turn after truncation works correctly', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume'); + + dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2'); + + client.clearReceived(); + + // Truncate to turn-tr1 + client.notify('dispatchAction', { + clientSeq: 3, + action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + // Send a new turn after truncation + dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 2); + assert.strictEqual(state.turns[0].id, 'turn-tr1'); + assert.strictEqual(state.turns[1].id, 'turn-tr3'); + }); + + // ---- Fork tests --------------------------------------------------------- + + test('fork creates a new session with source history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fork'); + + // Create two turns + dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2'); + + client.clearReceived(); + + // Fork at turn-f1 (keep turns up to and including turn-f1) + const forkedSessionUri = nextSessionUri(); + await client.call('createSession', { + session: forkedSessionUri, + provider: 'mock', + fork: { session: sessionUri, turnId: 'turn-f1' }, + }); + + const addedNotif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + + // Subscribe — forked session should have 1 turn (from the protocol state + // populated during createSession with fork params). + const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.lifecycle, 'ready'); + assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); + + // Source session should be unaffected + const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); + const sourceState = sourceSnapshot.snapshot.state as ISessionState; + assert.strictEqual(sourceState.turns.length, 2); + }); + + test('fork with invalid turn ID returns error', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid'); + + let gotError = false; + try { + await client.call('createSession', { + session: nextSessionUri(), + provider: 'mock', + fork: { session: sessionUri, turnId: 'nonexistent-turn' }, + }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should get error for invalid fork turn ID'); + }); + + test('fork with invalid source session returns error', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' }); + + let gotError = false; + try { + await client.call('createSession', { + session: nextSessionUri(), + provider: 'mock', + fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' }, + }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should get error for invalid fork source session'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/serverUrls.test.ts b/src/vs/platform/agentHost/test/node/serverUrls.test.ts new file mode 100644 index 0000000000000..61394085c53a9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/serverUrls.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { formatWebSocketUrl, resolveServerUrls } from '../../node/serverUrls.js'; + +suite('serverUrls', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses localhost for default local-only binding', () => { + assert.deepStrictEqual(resolveServerUrls(undefined, 8081), { + local: ['ws://localhost:8081'], + network: [], + }); + }); + + test('formats IPv6 websocket URLs with brackets', () => { + assert.strictEqual(formatWebSocketUrl('::1', 8081), 'ws://[::1]:8081'); + assert.deepStrictEqual(resolveServerUrls('::1', 8081), { + local: ['ws://[::1]:8081'], + network: [], + }); + assert.deepStrictEqual(resolveServerUrls('0000:0000:0000:0000:0000:0000:0000:0001', 8081), { + local: ['ws://[0000:0000:0000:0000:0000:0000:0000:0001]:8081'], + network: [], + }); + }); + + test('treats wildcard binding as localhost plus network urls', () => { + assert.deepStrictEqual(resolveServerUrls('0.0.0.0', 8081, { + lo0: [ + { address: '127.0.0.1', netmask: '255.0.0.0', family: 'IPv4', mac: '00:00:00:00:00:00', internal: true, cidr: '127.0.0.1/8' }, + ], + en0: [ + { address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', mac: '11:22:33:44:55:66', internal: false, cidr: '192.168.1.20/24' }, + { address: 'fe80::1', netmask: 'ffff:ffff:ffff:ffff::', family: 'IPv6', mac: '11:22:33:44:55:66', internal: false, cidr: 'fe80::1/64', scopeid: 0 }, + ], + }), { + local: ['ws://localhost:8081'], + network: ['ws://192.168.1.20:8081'], + }); + + assert.deepStrictEqual(resolveServerUrls('0000:0000:0000:0000:0000:0000:0000:0000', 8081, { + lo0: [ + { address: '127.0.0.1', netmask: '255.0.0.0', family: 'IPv4', mac: '00:00:00:00:00:00', internal: true, cidr: '127.0.0.1/8' }, + ], + en0: [ + { address: '192.168.1.20', netmask: '255.255.255.0', family: 'IPv4', mac: '11:22:33:44:55:66', internal: false, cidr: '192.168.1.20/24' }, + ], + }), { + local: ['ws://localhost:8081'], + network: ['ws://192.168.1.20:8081'], + }); + }); + + test('treats explicit non-loopback host as a network url', () => { + assert.deepStrictEqual(resolveServerUrls('example.test', 8081), { + local: [], + network: ['ws://example.test:8081'], + }); + }); +}); 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..f57a37d664f33 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * 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()); + }); +}); + +suite('SessionDataService — openDatabase ref-counting', () => { + + const disposables = new DisposableStore(); + const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' }); + let service: SessionDataService; + + setup(() => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + service = new SessionDataService(basePath, fileService, new NullLogService(), () => ':memory:'); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns a functional database reference', async () => { + const session = AgentSession.uri('copilot', 'ref-test'); + const ref = service.openDatabase(session); + disposables.add(ref); + + await ref.object.createTurn('turn-1'); + const edits = await ref.object.getFileEdits([]); + assert.deepStrictEqual(edits, []); + await ref.object.close(); + }); + + test('multiple references share the same database', async () => { + const session = AgentSession.uri('copilot', 'shared-test'); + const ref1 = service.openDatabase(session); + const ref2 = service.openDatabase(session); + + assert.strictEqual(ref1.object, ref2.object); + + ref1.dispose(); + ref2.dispose(); + await ref1.object.close(); + }); + + test('database remains usable until last reference is disposed', async () => { + const session = AgentSession.uri('copilot', 'refcount-test'); + const ref1 = service.openDatabase(session); + const ref2 = service.openDatabase(session); + + ref1.dispose(); + + // ref2 still works + await ref2.object.createTurn('turn-1'); + + ref2.dispose(); + + await ref1.object.close(); + }); + + test('new reference after all disposed gets a fresh database', async () => { + const session = AgentSession.uri('copilot', 'reopen-test'); + const ref1 = service.openDatabase(session); + const db1 = ref1.object; + ref1.dispose(); + + const ref2 = service.openDatabase(session); + disposables.add(ref2); + // New reference — may or may not be the same object, but must be functional + await ref2.object.createTurn('turn-1'); + assert.notStrictEqual(ref2.object, db1); + + await ref2.object.close(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts new file mode 100644 index 0000000000000..23d26104eb73a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -0,0 +1,492 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; +import type { Database } from '@vscode/sqlite3'; + +suite('SessionDatabase', () => { + + const disposables = new DisposableStore(); + let db: SessionDatabase | undefined; + let db2: SessionDatabase | undefined; + + teardown(async () => { + disposables.clear(); + await Promise.all([db?.close(), db2?.close()]); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Extends SessionDatabase to allow ejecting/injecting the raw sqlite3 + * Database instance, enabling reopen tests with :memory: databases. + */ + class TestableSessionDatabase extends SessionDatabase { + static override async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise { + const inst = new TestableSessionDatabase(path, migrations); + await inst._ensureDb(); + return inst; + } + + /** Extract the raw db connection; this instance becomes inert. */ + async ejectDb(): Promise { + const rawDb = await this._ensureDb(); + this._dbPromise = undefined; + this._closed = true; + return rawDb; + } + + /** Create a TestableSessionDatabase wrapping an existing raw db. */ + static async fromDb( + rawDb: Database, + migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations, + ): Promise { + await runMigrations(rawDb, migrations); + const inst = new TestableSessionDatabase(':memory:', migrations); + inst._dbPromise = Promise.resolve(rawDb); + return inst; + } + } + + // ---- Migration system ----------------------------------------------- + + suite('migrations', () => { + + test('applies all migrations on a fresh database', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, + ]; + + db = disposables.add(await SessionDatabase.open(':memory:', migrations)); + + const tables = (await db.getAllTables()).sort(); + assert.deepStrictEqual(tables, ['t1', 't2']); + }); + + test('reopening with same migrations is a no-op', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ]; + + const db1 = await TestableSessionDatabase.open(':memory:', migrations); + const rawDb = await db1.ejectDb(); + + // Reopen — should not throw (table already exists, migration skipped) + db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, migrations)); + assert.deepStrictEqual(await db2.getAllTables(), ['t1']); + }); + + test('only applies new migrations on reopen', async () => { + const v1: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ]; + const db1 = await TestableSessionDatabase.open(':memory:', v1); + const rawDb = await db1.ejectDb(); + + const v2: ISessionDatabaseMigration[] = [ + ...v1, + { version: 2, sql: 'CREATE TABLE t2 (id INTEGER PRIMARY KEY)' }, + ]; + db2 = disposables.add(await TestableSessionDatabase.fromDb(rawDb, v2)); + + const tables = (await db2.getAllTables()).sort(); + assert.deepStrictEqual(tables, ['t1', 't2']); + }); + + test('rolls back on migration failure', async () => { + const migrations: ISessionDatabaseMigration[] = [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + { version: 2, sql: 'THIS IS INVALID SQL' }, + ]; + + await assert.rejects(() => SessionDatabase.open(':memory:', migrations)); + + // A fresh :memory: open with valid migrations succeeds + db = disposables.add(await SessionDatabase.open(':memory:', [ + { version: 1, sql: 'CREATE TABLE t1 (id INTEGER PRIMARY KEY)' }, + ])); + assert.deepStrictEqual(await db.getAllTables(), ['t1']); + }); + }); + + // ---- File edits ----------------------------------------------------- + + suite('file edits', () => { + + test('store and retrieve a file edit', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: 5, + removedLines: 2, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.deepStrictEqual(edits, [{ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/file.ts', + originalPath: undefined, + addedLines: 5, + removedLines: 2, + }]); + }); + + test('retrieve multiple edits for a single tool call', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/a.ts', + beforeContent: new TextEncoder().encode('a-before'), + afterContent: new TextEncoder().encode('a-after'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/b.ts', + beforeContent: new TextEncoder().encode('b-before'), + afterContent: new TextEncoder().encode('b-after'), + addedLines: 1, + removedLines: 0, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 2); + assert.strictEqual(edits[0].filePath, '/workspace/a.ts'); + assert.strictEqual(edits[1].filePath, '/workspace/b.ts'); + }); + + test('retrieve edits across multiple tool calls', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/a.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('hello'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-2', + kind: FileEditKind.Edit, + filePath: '/workspace/b.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('world'), + addedLines: undefined, + removedLines: undefined, + }); + + const edits = await db.getFileEdits(['tc-1', 'tc-2']); + assert.strictEqual(edits.length, 2); + + // Only tc-2 + const edits2 = await db.getFileEdits(['tc-2']); + assert.strictEqual(edits2.length, 1); + assert.strictEqual(edits2[0].toolCallId, 'tc-2'); + }); + + test('returns empty array for unknown tool call IDs', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const edits = await db.getFileEdits(['nonexistent']); + assert.deepStrictEqual(edits, []); + }); + + test.skip('returns empty array when given empty array' /* Flaky https://github.com/microsoft/vscode/issues/306057 */, async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const edits = await db.getFileEdits([]); + assert.deepStrictEqual(edits, []); + }); + + test('replace on conflict (same toolCallId + filePath)', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('v1'), + afterContent: new TextEncoder().encode('v1-after'), + addedLines: 1, + removedLines: 0, + }); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('v2'), + afterContent: new TextEncoder().encode('v2-after'), + addedLines: 3, + removedLines: 1, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0].addedLines, 3); + + const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); + assert.ok(content); + assert.deepStrictEqual(new TextDecoder().decode(content.beforeContent), 'v2'); + }); + + test('readFileEditContent returns content on demand', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/file.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: undefined, + removedLines: undefined, + }); + + const content = await db.readFileEditContent('tc-1', '/workspace/file.ts'); + assert.ok(content); + assert.deepStrictEqual(content.beforeContent, new TextEncoder().encode('before')); + assert.deepStrictEqual(content.afterContent, new TextEncoder().encode('after')); + }); + + test('readFileEditContent returns undefined for missing edit', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const content = await db.readFileEditContent('tc-missing', '/no/such/file'); + assert.strictEqual(content, undefined); + }); + + test('persists binary content correctly', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const binary = new Uint8Array([0, 1, 2, 255, 128, 64]); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-bin', + kind: FileEditKind.Edit, + filePath: '/workspace/image.png', + beforeContent: new Uint8Array(0), + afterContent: binary, + addedLines: undefined, + removedLines: undefined, + }); + + const content = await db.readFileEditContent('tc-bin', '/workspace/image.png'); + assert.ok(content); + assert.deepStrictEqual(content.afterContent, binary); + }); + + test('auto-creates turn if it does not exist', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + // storeFileEdit should succeed even without a prior createTurn call + await db.storeFileEdit({ + turnId: 'auto-turn', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/x', + beforeContent: new Uint8Array(0), + afterContent: new Uint8Array(0), + addedLines: undefined, + removedLines: undefined, + }); + + const edits = await db.getFileEdits(['tc-1']); + assert.strictEqual(edits.length, 1); + assert.strictEqual(edits[0].turnId, 'auto-turn'); + }); + }); + + // ---- Turns ---------------------------------------------------------- + + suite('turns', () => { + + test('createTurn is idempotent', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.createTurn('turn-1'); + await db.createTurn('turn-1'); // should not throw + }); + + test('deleteTurn cascades to file edits', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/a.ts', + beforeContent: new TextEncoder().encode('before'), + afterContent: new TextEncoder().encode('after'), + addedLines: undefined, + removedLines: undefined, + }); + + // Edits exist + assert.strictEqual((await db.getFileEdits(['tc-1'])).length, 1); + + // Delete the turn — edits should be gone + await db.deleteTurn('turn-1'); + assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); + }); + + test('deleteTurn only removes its own edits', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.createTurn('turn-1'); + await db.createTurn('turn-2'); + await db.storeFileEdit({ + turnId: 'turn-1', + toolCallId: 'tc-1', + kind: FileEditKind.Edit, + filePath: '/workspace/a.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('a'), + addedLines: undefined, + removedLines: undefined, + }); + await db.storeFileEdit({ + turnId: 'turn-2', + toolCallId: 'tc-2', + kind: FileEditKind.Edit, + filePath: '/workspace/b.ts', + beforeContent: new Uint8Array(0), + afterContent: new TextEncoder().encode('b'), + addedLines: undefined, + removedLines: undefined, + }); + + await db.deleteTurn('turn-1'); + + assert.deepStrictEqual(await db.getFileEdits(['tc-1']), []); + assert.strictEqual((await db.getFileEdits(['tc-2'])).length, 1); + }); + + test('deleteTurn is a no-op for unknown turn', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.deleteTurn('nonexistent'); // should not throw + }); + }); + + // ---- Dispose -------------------------------------------------------- + + suite('dispose', () => { + + test('methods throw after dispose', async () => { + db = await SessionDatabase.open(':memory:'); + db.close(); + + await assert.rejects( + () => db!.createTurn('turn-1'), + /disposed/, + ); + }); + + test('double dispose is safe', async () => { + db = await SessionDatabase.open(':memory:'); + await db.close(); + await db.close(); // should not throw + }); + }); + + // ---- Lazy open ------------------------------------------------------ + + suite('lazy open', () => { + + test('constructor does not open the database', () => { + db = new SessionDatabase(':memory:'); + disposables.add(db); + // No error — the database is not opened until first use + }); + + test('first async call opens and migrates the database', async () => { + db = disposables.add(new SessionDatabase(':memory:')); + await db.createTurn('turn-1'); + const edits = await db.getFileEdits(['nonexistent']); + assert.deepStrictEqual(edits, []); + }); + + test('multiple concurrent calls share the same open promise', async () => { + db = disposables.add(new SessionDatabase(':memory:')); + // Fire multiple calls concurrently — all should succeed + await Promise.all([ + db.createTurn('turn-1'), + db.createTurn('turn-2'), + db.getFileEdits([]), + ]); + }); + + test('dispose during open rejects subsequent calls', async () => { + db = new SessionDatabase(':memory:'); + await db.close(); + await assert.rejects(() => db!.createTurn('turn-1'), /disposed/); + }); + }); + + // ---- Session metadata ----------------------------------------------- + + suite('session metadata', () => { + + test('getMetadata returns undefined for missing key', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + assert.strictEqual(await db.getMetadata('nonexistent'), undefined); + }); + + test('setMetadata and getMetadata round-trip', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('customTitle', 'My Session'); + assert.strictEqual(await db.getMetadata('customTitle'), 'My Session'); + }); + + test('setMetadata overwrites existing value', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('customTitle', 'First'); + await db.setMetadata('customTitle', 'Second'); + assert.strictEqual(await db.getMetadata('customTitle'), 'Second'); + }); + + test('metadata persists across reopen', async () => { + const db1 = disposables.add(await TestableSessionDatabase.open(':memory:')); + await db1.setMetadata('customTitle', 'Persistent Title'); + const rawDb = await db1.ejectDb(); + + db = disposables.add(await TestableSessionDatabase.fromDb(rawDb)); + assert.strictEqual(await db.getMetadata('customTitle'), 'Persistent Title'); + }); + + test('migration v2 creates session_metadata table', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('session_metadata')); + }); + }); +}); 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..e1e7a08830f40 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * 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 without 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, 0); + }); + + test('deleteSession clears state and emits notification', () => { + manager.createSession(makeSessionSummary()); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + manager.deleteSession(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..e6808f7e96f9a --- /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 \ + --agents \ + --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/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts new file mode 100644 index 0000000000000..9013bc40c1b87 --- /dev/null +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -0,0 +1,858 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parse as parseJSONC } from '../../../base/common/json.js'; +import { cloneAndChange } from '../../../base/common/objects.js'; +import { isAbsolute } from '../../../base/common/path.js'; +import { untildify } from '../../../base/common/labels.js'; +import { basename, extname, isEqualOrParent, joinPath, normalizePath } from '../../../base/common/resources.js'; +import { escapeRegExpCharacters } from '../../../base/common/strings.js'; +import { hasKey, Mutable } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; +import { IFileService } from '../../files/common/files.js'; +import { IMcpRemoteServerConfiguration, IMcpServerConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../mcp/common/mcpPlatformTypes.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single hook command to execute. Platform resolution happens at conversion time. */ +export interface IParsedHookCommand { + /** Cross-platform default command. */ + readonly command?: string; + /** Windows-specific command. */ + readonly windows?: string; + /** Linux-specific command. */ + readonly linux?: string; + /** macOS-specific command. */ + readonly osx?: string; + /** Working directory. */ + readonly cwd?: URI; + /** Environment variables. */ + readonly env?: Record; + /** Timeout in seconds. */ + readonly timeout?: number; +} + +/** A group of hooks for a single lifecycle event. */ +export interface IParsedHookGroup { + /** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */ + readonly type: string; + /** The commands to execute for this hook type. */ + readonly commands: readonly IParsedHookCommand[]; + /** URI where this hook is defined. */ + readonly uri: URI; + /** Original key as it appears in the hook file. */ + readonly originalId: string; +} + +export interface IMcpServerDefinition { + readonly name: string; + readonly configuration: IMcpServerConfiguration; + readonly uri: URI; +} + +/** A named resource (skill, agent, command, or instruction) within a plugin. */ +export interface INamedPluginResource { + readonly uri: URI; + readonly name: string; +} + +/** The result of parsing a single plugin directory. */ +export interface IParsedPlugin { + readonly hooks: readonly IParsedHookGroup[]; + readonly mcpServers: readonly IMcpServerDefinition[]; + readonly skills: readonly INamedPluginResource[]; + readonly agents: readonly INamedPluginResource[]; +} + +// --------------------------------------------------------------------------- +// Plugin format detection +// --------------------------------------------------------------------------- + +export const enum PluginFormat { + Copilot, + Claude, + OpenPlugin, +} + +export interface IPluginFormatConfig { + readonly format: PluginFormat; + readonly manifestPath: string; + readonly hookConfigPath: string; + readonly pluginRootToken: string | undefined; + readonly pluginRootEnvVar: string | undefined; + /** Parses hooks from a JSON object using the format's conventions. */ + parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: string): IParsedHookGroup[]; +} + +const COPILOT_FORMAT: IPluginFormatConfig = { + format: PluginFormat.Copilot, + manifestPath: 'plugin.json', + hookConfigPath: 'hooks.json', + pluginRootToken: undefined, + pluginRootEnvVar: undefined, + parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) { + return parseHooksJson(hookUri, json, workspaceRoot, userHome); + }, +}; + +const CLAUDE_FORMAT: IPluginFormatConfig = { + format: PluginFormat.Claude, + manifestPath: '.claude-plugin/plugin.json', + hookConfigPath: 'hooks/hooks.json', + pluginRootToken: '${CLAUDE_PLUGIN_ROOT}', + pluginRootEnvVar: 'CLAUDE_PLUGIN_ROOT', + parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) { + return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); + }, +}; + +const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = { + format: PluginFormat.OpenPlugin, + manifestPath: '.plugin/plugin.json', + hookConfigPath: 'hooks/hooks.json', + pluginRootToken: '${PLUGIN_ROOT}', + pluginRootEnvVar: 'PLUGIN_ROOT', + parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) { + return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); + }, +}; + +export async function detectPluginFormat(pluginUri: URI, fileService: IFileService): Promise { + if (await pathExists(joinPath(pluginUri, '.plugin', 'plugin.json'), fileService)) { + return OPEN_PLUGIN_FORMAT; + } + + const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude'); + if (isInClaudeDirectory || await pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'), fileService)) { + return CLAUDE_FORMAT; + } + + return COPILOT_FORMAT; +} + +// --------------------------------------------------------------------------- +// Component path config +// --------------------------------------------------------------------------- + +export interface IComponentPathConfig { + readonly paths: readonly string[]; + readonly exclusive: boolean; +} + +const emptyComponentPathConfig: IComponentPathConfig = { paths: [], exclusive: false }; + +/** + * Parses a manifest component path field into a normalized config. + * Supports `undefined`, `string`, `string[]`, and `{ paths: string[], exclusive?: boolean }`. + */ +export function parseComponentPathConfig(raw: unknown): IComponentPathConfig { + if (raw === undefined || raw === null) { + return emptyComponentPathConfig; + } + + if (typeof raw === 'string') { + const trimmed = raw.trim(); + return trimmed ? { paths: [trimmed], exclusive: false } : emptyComponentPathConfig; + } + + if (Array.isArray(raw)) { + const paths = raw + .filter(v => typeof v === 'string') + .map(v => v.trim()) + .filter(v => v.length > 0); + return { paths, exclusive: false }; + } + + if (typeof raw === 'object') { + const obj = raw as Record; + if (Array.isArray(obj['paths'])) { + const paths = (obj['paths'] as unknown[]) + .filter(v => typeof v === 'string') + .map(v => v.trim()) + .filter(v => v.length > 0); + const exclusive = obj['exclusive'] === true; + return { paths, exclusive }; + } + } + + return emptyComponentPathConfig; +} + +/** + * Resolves the directories to scan for a given component type, combining + * the default directory with any custom paths from the manifest config. + * Paths that resolve outside the plugin root are silently ignored. + */ +export function resolveComponentDirs(pluginUri: URI, defaultDir: string, config: IComponentPathConfig): readonly URI[] { + const dirs: URI[] = []; + if (!config.exclusive) { + dirs.push(joinPath(pluginUri, defaultDir)); + } + for (const p of config.paths) { + const resolved = normalizePath(joinPath(pluginUri, p)); + if (isEqualOrParent(resolved, pluginUri)) { + dirs.push(resolved); + } + } + return dirs; +} + +// --------------------------------------------------------------------------- +// MCP server helpers +// --------------------------------------------------------------------------- + +/** + * Extracts the MCP server map from a raw JSON value. Accepts both the + * wrapped format `{ mcpServers: { … } }` and the flat format. + */ +export function resolveMcpServersMap(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return undefined; + } + const obj = raw as Record; + return Object.hasOwn(obj, 'mcpServers') + ? (obj.mcpServers as Record) + : obj; +} + +/** + * Normalizes a raw JSON value into a typed MCP server configuration. + */ +export function normalizeMcpServerConfiguration(rawConfig: unknown): IMcpServerConfiguration | undefined { + if (!rawConfig || typeof rawConfig !== 'object') { + return undefined; + } + + const candidate = rawConfig as Record; + const type = typeof candidate['type'] === 'string' ? candidate['type'] : undefined; + + const command = typeof candidate['command'] === 'string' ? candidate['command'] : undefined; + const url = typeof candidate['url'] === 'string' ? candidate['url'] : undefined; + const args = Array.isArray(candidate['args']) ? candidate['args'].filter((value): value is string => typeof value === 'string') : undefined; + const env = candidate['env'] && typeof candidate['env'] === 'object' + ? Object.fromEntries(Object.entries(candidate['env'] as Record) + .filter(([, value]) => typeof value === 'string' || typeof value === 'number' || value === null) + .map(([key, value]) => [key, value as string | number | null])) + : undefined; + const envFile = typeof candidate['envFile'] === 'string' ? candidate['envFile'] : undefined; + const cwd = typeof candidate['cwd'] === 'string' ? candidate['cwd'] : undefined; + const headers = candidate['headers'] && typeof candidate['headers'] === 'object' + ? Object.fromEntries(Object.entries(candidate['headers'] as Record) + .filter(([, value]) => typeof value === 'string') + .map(([key, value]) => [key, value as string])) + : undefined; + const dev = candidate['dev'] && typeof candidate['dev'] === 'object' ? candidate['dev'] as IMcpStdioServerConfiguration['dev'] : undefined; + + if (type === 'ws') { + return undefined; + } + + if (type === McpServerType.LOCAL || (!type && command)) { + if (!command) { + return undefined; + } + return { type: McpServerType.LOCAL, command, args, env, envFile, cwd, dev }; + } + + if (type === McpServerType.REMOTE || type === 'sse' || (!type && url)) { + if (!url) { + return undefined; + } + return { type: McpServerType.REMOTE, url, headers, dev }; + } + + return undefined; +} + +/** + * Characters in a file path that require shell quoting to prevent + * word splitting or interpretation by common shells. + */ +const shellUnsafeChars = /[\s&|<>()^;!`"']/; + +/** + * Replaces a plugin-root token in a shell command string with the + * given fsPath, shell-quoting if the path contains special characters. + */ +export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string) { + if (!command.includes(token)) { + return command; + } + + if (!shellUnsafeChars.test(fsPath)) { + return command.replaceAll(token, fsPath); + } + + const escapedToken = escapeRegExpCharacters(token); + const pattern = new RegExp( + `(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`, + 'g', + ); + + return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => { + const fullPath = fsPath + suffix; + if (leadingQuote) { + return leadingQuote + fullPath; + } + return '"' + fullPath.replace(/"/g, '\\"') + '"'; + }); +} + +/** + * Replaces plugin-root token references in MCP server definition string fields + * with the plugin root filesystem path. + */ +export function interpolateMcpPluginRoot( + def: IMcpServerDefinition, + fsPath: string, + token: string, + envVar: string, +): IMcpServerDefinition { + const replace = (s: string) => s.replaceAll(token, fsPath); + + const config = def.configuration; + let interpolated: IMcpServerConfiguration; + + if (config.type === McpServerType.LOCAL) { + const local: Mutable = { ...config }; + local.command = replace(local.command); + if (local.args) { + local.args = local.args.map(replace); + } + if (local.cwd) { + local.cwd = replace(local.cwd); + } + local.env = { ...local.env }; + for (const [k, v] of Object.entries(local.env)) { + if (typeof v === 'string') { + local.env[k] = replace(v); + } + } + local.env[envVar] = fsPath; + if (local.envFile) { + local.envFile = replace(local.envFile); + } + interpolated = local; + } else { + const remote: Mutable = { ...config }; + remote.url = replace(remote.url); + if (remote.headers) { + remote.headers = Object.fromEntries( + Object.entries(remote.headers).map(([k, v]) => [k, replace(v)]) + ); + } + interpolated = remote; + } + + return { name: def.name, configuration: interpolated, uri: def.uri }; +} + +/** + * Regex matching bare `${VAR_NAME}` references (uppercase only) that are NOT + * using VS Code's `${env:VAR}` colon-delimited syntax. + */ +const BARE_ENV_VAR_RE = /\$\{(?![A-Za-z]+:)([A-Z_][A-Z0-9_]*)\}/g; + +/** + * Converts bare `${VAR}` environment-variable references to VS Code `${env:VAR}` syntax. + */ +export function convertBareEnvVarsToVsCodeSyntax( + def: IMcpServerDefinition, +): IMcpServerDefinition { + return cloneAndChange(def, (value) => { + if (URI.isUri(value)) { + return value; + } + if (typeof value === 'string') { + const replaced = value.replace(BARE_ENV_VAR_RE, '${env:$1}'); + return replaced !== value ? replaced : undefined; + } + return undefined; + }); +} + +// --------------------------------------------------------------------------- +// Hook parsing helpers +// --------------------------------------------------------------------------- + +/** + * Maps known hook type identifiers from all formats (VS Code PascalCase, + * Copilot CLI camelCase, Claude PascalCase) to canonical identifiers. + */ +const HOOK_TYPE_MAP: Record = { + // PascalCase (VS Code / Claude) + 'SessionStart': 'SessionStart', + 'SessionEnd': 'SessionEnd', + 'UserPromptSubmit': 'UserPromptSubmit', + 'PreToolUse': 'PreToolUse', + 'PostToolUse': 'PostToolUse', + 'PreCompact': 'PreCompact', + 'SubagentStart': 'SubagentStart', + 'SubagentStop': 'SubagentStop', + 'Stop': 'Stop', + 'ErrorOccurred': 'ErrorOccurred', + // camelCase (GitHub Copilot CLI) + 'sessionStart': 'SessionStart', + 'sessionEnd': 'SessionEnd', + 'userPromptSubmitted': 'UserPromptSubmit', + 'preToolUse': 'PreToolUse', + 'postToolUse': 'PostToolUse', + 'agentStop': 'Stop', + 'subagentStop': 'SubagentStop', + 'errorOccurred': 'ErrorOccurred', +}; + +/** + * Normalizes a raw hook command object, validating structure and mapping + * legacy `bash`/`powershell` fields to platform-specific overrides. + */ +function normalizeHookCommand(raw: Record): IParsedHookCommand | undefined { + // Allow omitted type (Claude compatibility) — treat as 'command' + if (raw.type !== undefined && raw.type !== 'command') { + return undefined; + } + + const hasCommand = typeof raw.command === 'string' && raw.command.length > 0; + const hasBash = typeof raw.bash === 'string' && (raw.bash as string).length > 0; + const hasPowerShell = typeof raw.powershell === 'string' && (raw.powershell as string).length > 0; + const hasWindows = typeof raw.windows === 'string' && (raw.windows as string).length > 0; + const hasLinux = typeof raw.linux === 'string' && (raw.linux as string).length > 0; + const hasOsx = typeof raw.osx === 'string' && (raw.osx as string).length > 0; + + if (!hasCommand && !hasBash && !hasPowerShell && !hasWindows && !hasLinux && !hasOsx) { + return undefined; + } + + const windows = hasWindows ? raw.windows as string : (hasPowerShell ? raw.powershell as string : undefined); + const linux = hasLinux ? raw.linux as string : (hasBash ? raw.bash as string : undefined); + const osx = hasOsx ? raw.osx as string : (hasBash ? raw.bash as string : undefined); + + const timeout = typeof raw.timeout === 'number' + ? raw.timeout + : (typeof raw.timeoutSec === 'number' ? raw.timeoutSec : undefined); + + return { + ...(hasCommand && { command: raw.command as string }), + ...(windows && { windows }), + ...(linux && { linux }), + ...(osx && { osx }), + ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), + ...(timeout !== undefined && { timeout }), + }; +} + +/** + * Resolves a raw hook command JSON object into a {@link IParsedHookCommand}, + * normalizing fields and resolving the working directory. + */ +function resolveHookCommand(raw: Record, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand | undefined { + const normalized = normalizeHookCommand(raw); + if (!normalized) { + return undefined; + } + + let cwdUri: URI | undefined; + const rawCwd = typeof raw.cwd === 'string' ? raw.cwd : undefined; + if (rawCwd) { + const expanded = untildify(rawCwd, userHome); + if (isAbsolute(expanded)) { + cwdUri = URI.file(expanded); + } else if (workspaceRoot) { + cwdUri = joinPath(workspaceRoot, expanded); + } + } else { + cwdUri = workspaceRoot; + } + + return { ...normalized, cwd: cwdUri }; +} + +/** + * Extracts hook commands from an item that may be a direct command object + * or a nested structure with a `matcher` (Claude format). + */ +function extractHookCommands(item: unknown, workspaceRoot: URI | undefined, userHome: string): IParsedHookCommand[] { + if (!item || typeof item !== 'object') { + return []; + } + + const itemObj = item as Record; + const commands: IParsedHookCommand[] = []; + + // Nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } + const nestedHooks = itemObj.hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nested of nestedHooks) { + if (!nested || typeof nested !== 'object') { + continue; + } + const resolved = resolveHookCommand(nested as Record, workspaceRoot, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + const resolved = resolveHookCommand(itemObj, workspaceRoot, userHome); + if (resolved) { + commands.push(resolved); + } + } + + return commands; +} + +/** + * Parses hooks from a JSON object (any supported format). + */ +function parseHooksJson( + hookUri: URI, + json: unknown, + workspaceRoot: URI | undefined, + userHome: string, +): IParsedHookGroup[] { + if (!json || typeof json !== 'object') { + return []; + } + + const root = json as Record; + + // Claude's disableAllHooks + if (root.disableAllHooks === true) { + return []; + } + + const hooks = root.hooks; + if (!hooks || typeof hooks !== 'object') { + return []; + } + + const hooksObj = hooks as Record; + const result: IParsedHookGroup[] = []; + + for (const originalId of Object.keys(hooksObj)) { + const canonicalType = HOOK_TYPE_MAP[originalId]; + if (!canonicalType) { + continue; + } + + const hookArray = hooksObj[originalId]; + if (!Array.isArray(hookArray)) { + continue; + } + + const commands: IParsedHookCommand[] = []; + for (const item of hookArray) { + commands.push(...extractHookCommands(item, workspaceRoot, userHome)); + } + + if (commands.length > 0) { + result.push({ type: canonicalType, commands, uri: hookUri, originalId }); + } + } + + return result; +} + +/** + * Applies plugin-root token interpolation to hook commands for + * Claude and OpenPlugin formats. + */ +export function interpolateHookPluginRoot( + hookUri: URI, + json: unknown, + pluginUri: URI, + workspaceRoot: URI | undefined, + userHome: string, + token: string, + envVar: string, +): IParsedHookGroup[] { + const fsPath = pluginUri.fsPath; + const typedJson = json as { hooks?: Record }; + + const mutateHookCommand = (hook: Record): void => { + for (const field of ['command', 'windows', 'linux', 'osx'] as const) { + if (typeof hook[field] === 'string') { + hook[field] = shellQuotePluginRootInCommand(hook[field] as string, fsPath, token); + } + } + + if (!hook.env || typeof hook.env !== 'object') { + hook.env = {}; + } + (hook.env as Record)[envVar] = fsPath; + }; + + for (const lifecycle of Object.values(typedJson.hooks ?? {})) { + if (!Array.isArray(lifecycle)) { + continue; + } + for (const lifecycleEntry of lifecycle) { + if (!lifecycleEntry || typeof lifecycleEntry !== 'object') { + continue; + } + const entry = lifecycleEntry as { hooks?: Record[] } & Record; + if (Array.isArray(entry.hooks)) { + for (const hook of entry.hooks) { + mutateHookCommand(hook); + } + } else { + mutateHookCommand(entry); + } + } + } + + const replacer = (v: unknown): unknown => { + return typeof v === 'string' + ? v.replaceAll(token, pluginUri.fsPath) + : undefined; + }; + + return parseHooksJson(hookUri, cloneAndChange(json, replacer), workspaceRoot, userHome); +} + +// --------------------------------------------------------------------------- +// Filesystem helpers +// --------------------------------------------------------------------------- + +export async function readJsonFile(uri: URI, fileService: IFileService): Promise { + try { + const fileContents = await fileService.readFile(uri); + return parseJSONC(fileContents.value.toString()); + } catch { + return undefined; + } +} + +export async function pathExists(resource: URI, fileService: IFileService): Promise { + try { + await fileService.resolve(resource); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Component readers +// --------------------------------------------------------------------------- + +const COMMAND_FILE_SUFFIX = '.md'; + +export async function readSkills(pluginRoot: URI, dirs: readonly URI[], fileService: IFileService): Promise { + const seen = new Set(); + const skills: INamedPluginResource[] = []; + + const addSkill = (name: string, skillMd: URI) => { + if (!seen.has(name)) { + seen.add(name); + skills.push({ uri: skillMd, name }); + } + }; + + for (const dir of dirs) { + const skillMd = URI.joinPath(dir, 'SKILL.md'); + if (await pathExists(skillMd, fileService)) { + addSkill(basename(dir), skillMd); + continue; + } + + let stat; + try { + stat = await fileService.resolve(dir); + } catch { + continue; + } + + if (!stat.isDirectory || !stat.children) { + continue; + } + + for (const child of stat.children) { + const childSkillMd = URI.joinPath(child.resource, 'SKILL.md'); + if (await pathExists(childSkillMd, fileService)) { + addSkill(basename(child.resource), childSkillMd); + } + } + } + + if (skills.length === 0) { + const rootSkillMd = URI.joinPath(pluginRoot, 'SKILL.md'); + if (await pathExists(rootSkillMd, fileService)) { + addSkill(basename(pluginRoot), rootSkillMd); + } + } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + +export async function readMarkdownComponents(dirs: readonly URI[], fileService: IFileService): Promise { + const seen = new Set(); + const items: INamedPluginResource[] = []; + + const addItem = (name: string, uri: URI) => { + if (!seen.has(name)) { + seen.add(name); + items.push({ uri, name }); + } + }; + + for (const dir of dirs) { + let stat; + try { + stat = await fileService.resolve(dir); + } catch { + continue; + } + + if (stat.isFile && extname(dir).toLowerCase() === COMMAND_FILE_SUFFIX) { + addItem(basename(dir).slice(0, -COMMAND_FILE_SUFFIX.length), dir); + continue; + } + + if (!stat.isDirectory || !stat.children) { + continue; + } + + for (const child of stat.children) { + if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) { + continue; + } + addItem(basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length), child.resource); + } + } + + items.sort((a, b) => a.name.localeCompare(b.name)); + return items; +} + +async function readHooks( + pluginUri: URI, + paths: readonly URI[], + formatConfig: IPluginFormatConfig, + fileService: IFileService, + workspaceRoot: URI | undefined, + userHome: string, +): Promise { + for (const hookPath of paths) { + const json = await readJsonFile(hookPath, fileService); + if (!json) { + continue; + } + + return formatConfig.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome); + } + return []; +} + +async function readMcpServers( + paths: readonly URI[], + pluginFsPath: string, + formatConfig: IPluginFormatConfig, + fileService: IFileService, +): Promise { + const merged = new Map(); + for (const mcpPath of paths) { + const json = await readJsonFile(mcpPath, fileService); + for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, formatConfig)) { + if (!merged.has(def.name)) { + merged.set(def.name, def); + } + } + } + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export function parseMcpServerDefinitionMap( + definitionURI: URI, + raw: unknown, + pluginFsPath: string, + formatConfig: IPluginFormatConfig, +): IMcpServerDefinition[] { + const mcpServers = resolveMcpServersMap(raw); + if (!mcpServers) { + return []; + } + + const definitions: IMcpServerDefinition[] = []; + for (const [name, configValue] of Object.entries(mcpServers)) { + const configuration = normalizeMcpServerConfiguration(configValue); + if (!configuration) { + continue; + } + + let def: IMcpServerDefinition = { name, configuration, uri: definitionURI }; + if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) { + def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar); + } + def = convertBareEnvVarsToVsCodeSyntax(def); + definitions.push(def); + } + + return definitions; +} + +// --------------------------------------------------------------------------- +// Top-level parse function +// --------------------------------------------------------------------------- + +/** + * Parses a plugin directory to extract hooks, MCP servers, skills, and agents. + * This is the main entry point for the agent host to discover plugin contents. + */ +export async function parsePlugin( + pluginUri: URI, + fileService: IFileService, + workspaceRoot: URI | undefined, + userHome: string, +): Promise { + const formatConfig = await detectPluginFormat(pluginUri, fileService); + + // Read manifest + const manifestJson = await readJsonFile(joinPath(pluginUri, formatConfig.manifestPath), fileService); + const manifest = (manifestJson && typeof manifestJson === 'object') ? manifestJson as Record : undefined; + + // Resolve component directories from manifest + const hookDirs = resolveComponentDirs(pluginUri, formatConfig.hookConfigPath, parseComponentPathConfig(manifest?.['hooks'])); + const mcpDirs = resolveComponentDirs(pluginUri, '.mcp.json', parseComponentPathConfig(manifest?.['mcpServers'])); + const skillDirs = resolveComponentDirs(pluginUri, 'skills', parseComponentPathConfig(manifest?.['skills'])); + const agentDirs = resolveComponentDirs(pluginUri, 'agents', parseComponentPathConfig(manifest?.['agents'])); + + // Handle embedded MCP servers in manifest + let embeddedMcp: IMcpServerDefinition[] = []; + const mcpSection = manifest?.['mcpServers']; + if (mcpSection && typeof mcpSection === 'object' && !Array.isArray(mcpSection) && !(hasKey(mcpSection, { paths: true }))) { + embeddedMcp = parseMcpServerDefinitionMap( + joinPath(pluginUri, formatConfig.manifestPath), + { mcpServers: mcpSection }, + pluginUri.fsPath, + formatConfig, + ); + } + + // Handle embedded hooks in manifest + let embeddedHooks: IParsedHookGroup[] = []; + const hooksSection = manifest?.['hooks']; + if (hooksSection && typeof hooksSection === 'object' && !Array.isArray(hooksSection) && !(hasKey(hooksSection, { paths: true }))) { + const manifestUri = joinPath(pluginUri, formatConfig.manifestPath); + embeddedHooks = formatConfig.parseHooks(manifestUri, { hooks: hooksSection }, pluginUri, workspaceRoot, userHome); + } + + const [hooks, mcpServers, skills, agents] = await Promise.all([ + embeddedHooks.length > 0 + ? Promise.resolve(embeddedHooks) + : readHooks(pluginUri, hookDirs, formatConfig, fileService, workspaceRoot, userHome), + embeddedMcp.length > 0 + ? Promise.resolve(embeddedMcp) + : readMcpServers(mcpDirs, pluginUri.fsPath, formatConfig, fileService), + readSkills(pluginUri, skillDirs, fileService), + readMarkdownComponents(agentDirs, fileService), + ]); + + return { hooks, mcpServers, skills, agents }; +} + diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts new file mode 100644 index 0000000000000..afc3121f922f1 --- /dev/null +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; +import { + parseComponentPathConfig, + resolveComponentDirs, + normalizeMcpServerConfiguration, + shellQuotePluginRootInCommand, + convertBareEnvVarsToVsCodeSyntax, +} from '../../common/pluginParsers.js'; + +suite('pluginParsers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- parseComponentPathConfig --------------------------------------- + + suite('parseComponentPathConfig', () => { + + test('returns empty config for undefined', () => { + const result = parseComponentPathConfig(undefined); + assert.deepStrictEqual(result, { paths: [], exclusive: false }); + }); + + test('returns empty config for null', () => { + const result = parseComponentPathConfig(null); + assert.deepStrictEqual(result, { paths: [], exclusive: false }); + }); + + test('parses a string to single-element paths', () => { + const result = parseComponentPathConfig('custom/skills'); + assert.deepStrictEqual(result, { paths: ['custom/skills'], exclusive: false }); + }); + + test('trims whitespace from string', () => { + const result = parseComponentPathConfig(' spaced '); + assert.deepStrictEqual(result, { paths: ['spaced'], exclusive: false }); + }); + + test('returns empty for blank string', () => { + const result = parseComponentPathConfig(' '); + assert.deepStrictEqual(result, { paths: [], exclusive: false }); + }); + + test('parses a string array', () => { + const result = parseComponentPathConfig(['a', 'b', 'c']); + assert.deepStrictEqual(result, { paths: ['a', 'b', 'c'], exclusive: false }); + }); + + test('filters non-string entries from arrays', () => { + const result = parseComponentPathConfig(['valid', 42, null, 'ok']); + assert.deepStrictEqual(result, { paths: ['valid', 'ok'], exclusive: false }); + }); + + test('parses object with paths and exclusive', () => { + const result = parseComponentPathConfig({ paths: ['x', 'y'], exclusive: true }); + assert.deepStrictEqual(result, { paths: ['x', 'y'], exclusive: true }); + }); + + test('object without exclusive defaults to false', () => { + const result = parseComponentPathConfig({ paths: ['z'] }); + assert.deepStrictEqual(result, { paths: ['z'], exclusive: false }); + }); + + test('returns empty for unrecognized types', () => { + const result = parseComponentPathConfig(42); + assert.deepStrictEqual(result, { paths: [], exclusive: false }); + }); + }); + + // ---- resolveComponentDirs ------------------------------------------- + + suite('resolveComponentDirs', () => { + + const pluginUri = URI.file('/workspace/.plugin-root'); + + test('includes default directory when not exclusive', () => { + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: [], exclusive: false }); + assert.strictEqual(dirs.length, 1); + assert.ok(dirs[0].path.endsWith('/skills')); + }); + + test('excludes default directory when exclusive', () => { + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['custom'], exclusive: true }); + assert.ok(!dirs.some(d => d.path.endsWith('/skills'))); + assert.ok(dirs.some(d => d.path.endsWith('/custom'))); + }); + + test('resolves relative paths from plugin root', () => { + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['other/skills'], exclusive: false }); + assert.strictEqual(dirs.length, 2); + assert.ok(dirs[1].path.endsWith('/other/skills')); + }); + + test('rejects paths that escape plugin root', () => { + const dirs = resolveComponentDirs(pluginUri, 'skills', { paths: ['../../outside'], exclusive: false }); + // Should only have the default dir, the traversal path is rejected + assert.strictEqual(dirs.length, 1); + }); + }); + + // ---- normalizeMcpServerConfiguration -------------------------------- + + suite('normalizeMcpServerConfiguration', () => { + + test('returns undefined for non-object input', () => { + assert.strictEqual(normalizeMcpServerConfiguration(null), undefined); + assert.strictEqual(normalizeMcpServerConfiguration('string'), undefined); + assert.strictEqual(normalizeMcpServerConfiguration(42), undefined); + }); + + test('parses local server with command', () => { + const result = normalizeMcpServerConfiguration({ + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { KEY: 'value' }, + cwd: '/workspace', + }); + assert.ok(result); + assert.strictEqual(result!.type, McpServerType.LOCAL); + assert.strictEqual((result as { command: string }).command, 'node'); + }); + + test('infers local type from command without explicit type', () => { + const result = normalizeMcpServerConfiguration({ command: 'python' }); + assert.ok(result); + assert.strictEqual(result!.type, McpServerType.LOCAL); + }); + + test('parses remote server with url', () => { + const result = normalizeMcpServerConfiguration({ + type: 'sse', + url: 'https://example.com', + headers: { 'X-Key': 'val' }, + }); + assert.ok(result); + assert.strictEqual(result!.type, McpServerType.REMOTE); + }); + + test('infers remote type from url without explicit type', () => { + const result = normalizeMcpServerConfiguration({ url: 'https://example.com' }); + assert.ok(result); + assert.strictEqual(result!.type, McpServerType.REMOTE); + }); + + test('rejects ws type', () => { + const result = normalizeMcpServerConfiguration({ type: 'ws', url: 'ws://localhost:3000' }); + assert.strictEqual(result, undefined); + }); + + test('rejects local type without command', () => { + const result = normalizeMcpServerConfiguration({ type: 'stdio' }); + assert.strictEqual(result, undefined); + }); + + test('filters non-string args', () => { + const result = normalizeMcpServerConfiguration({ + command: 'test', + args: ['valid', 42, null, 'also-valid'], + }); + assert.ok(result); + const args = (result as { args?: string[] }).args; + assert.deepStrictEqual(args, ['valid', 'also-valid']); + }); + }); + + // ---- shellQuotePluginRootInCommand ----------------------------------- + + suite('shellQuotePluginRootInCommand', () => { + + test('replaces token with path when no special chars', () => { + const result = shellQuotePluginRootInCommand( + 'cd ${PLUGIN_ROOT} && run', + '/simple/path', + '${PLUGIN_ROOT}' + ); + assert.strictEqual(result, 'cd /simple/path && run'); + }); + + test('quotes path with spaces', () => { + const result = shellQuotePluginRootInCommand( + 'cd ${PLUGIN_ROOT} && run', + '/path with spaces', + '${PLUGIN_ROOT}' + ); + assert.ok(result.includes('"'), 'should add quotes for path with spaces'); + assert.ok(result.includes('/path with spaces')); + }); + + test('returns unchanged when token not present', () => { + const result = shellQuotePluginRootInCommand('echo hello', '/path', '${PLUGIN_ROOT}'); + assert.strictEqual(result, 'echo hello'); + }); + + test('handles already-quoted token', () => { + const result = shellQuotePluginRootInCommand( + '"${PLUGIN_ROOT}/script.sh"', + '/path with spaces', + '${PLUGIN_ROOT}' + ); + assert.ok(!result.includes('""'), 'should not double-quote'); + }); + }); + + // ---- convertBareEnvVarsToVsCodeSyntax ------------------------------- + + suite('convertBareEnvVarsToVsCodeSyntax', () => { + + test('converts bare env vars to VS Code syntax', () => { + const def = { + name: 'test', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL as const, + command: '${MY_TOOL}', + args: ['--key=${API_KEY}'], + }, + }; + const result = convertBareEnvVarsToVsCodeSyntax(def); + assert.strictEqual((result.configuration as { command: string }).command, '${env:MY_TOOL}'); + assert.deepStrictEqual((result.configuration as unknown as { args: string[] }).args, ['--key=${env:API_KEY}']); + }); + + test('does not convert already-qualified vars', () => { + const def = { + name: 'test', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL as const, + command: '${env:ALREADY_QUALIFIED}', + }, + }; + const result = convertBareEnvVarsToVsCodeSyntax(def); + assert.strictEqual((result.configuration as { command: string }).command, '${env:ALREADY_QUALIFIED}'); + }); + + test('ignores lowercase vars', () => { + const def = { + name: 'test', + uri: URI.file('/plugin'), + configuration: { + type: McpServerType.LOCAL as const, + command: '${lowercase}', + }, + }; + const result = convertBareEnvVarsToVsCodeSyntax(def); + assert.strictEqual((result.configuration as { command: string }).command, '${lowercase}'); + }); + }); +}); 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..ca9ad6d51f526 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,48 @@ 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 { IElementData } from '../../browserElements/common/browserElements.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,11 +55,13 @@ export interface IBrowserViewBounds { width: number; height: number; zoomFactor: number; + cornerRadius: number; } export interface IBrowserViewCaptureScreenshotOptions { quality?: number; - rect?: { x: number; y: number; width: number; height: number }; + screenRect?: { x: number; y: number; width: number; height: number }; + pageRect?: { x: number; y: number; width: number; height: number }; } export interface IBrowserViewState { @@ -33,7 +76,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 +86,7 @@ export interface IBrowserViewNavigationEvent { title: string; canGoBack: boolean; canGoForward: boolean; + certificateError: IBrowserViewCertificateError | undefined; } export interface IBrowserViewLoadingEvent { @@ -52,6 +98,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 +150,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 +178,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 +226,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 +276,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 +306,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 +351,61 @@ 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; + + /** + * Get captured console logs for a browser view. + * Console messages are automatically captured from the moment the view is created. + * @param id The browser view identifier + * @returns The captured console logs as a single string + */ + getConsoleLogs(id: string): Promise; + + /** + * Start element inspection mode in a browser view. Sets up a CDP overlay that + * highlights elements on hover. When the user clicks an element, its data is + * returned and the overlay is removed. + * @param id The browser view identifier + * @param cancellationId An identifier that can be passed to {@link cancel} to abort + * @returns The inspected element data, or undefined if cancelled + */ + getElementData(id: string, cancellationId: number): Promise; + + /** + * Get element data for the currently focused element in the browser view. + * @param id The browser view identifier + * @returns The focused element's data, or undefined if no element is focused + */ + getFocusedElementData(id: string): Promise; + + /** + * Cancel an in-progress request. + */ + cancel(cancellationId: number): 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..8f9c2694c652e 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -4,11 +4,18 @@ *--------------------------------------------------------------------------------------------*/ 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'); +export interface IInvokeFunctionResult { + result?: unknown; + error?: string; + summary: string; + /** When present the function did not complete within the timeout. Pass this ID to {@link IPlaywrightService.waitForDeferredResult} to keep waiting. */ + deferredResultId?: string; +} + /** * A service for using Playwright to connect to and automate the integrated browser. * @@ -63,23 +70,42 @@ 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. + */ + invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise; + + /** + * 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. + * + * When {@link timeoutMs} is provided, the call races against that timeout. + * If the timeout fires before the function completes, or the function is otherwise interrupted, + * the in-flight promise is stored as a *deferred result* and the returned object includes a + * {@link deferredResultId} that can be passed to {@link waitForDeferredResult} to resume waiting. + * When {@link timeoutMs} is omitted the function runs to completion with no deferral. + * * @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. + * @param timeoutMs Maximum time (in ms) to wait for the function to complete before deferring. When omitted the call awaits indefinitely. + * @returns The result of the function execution, including a page summary and optionally a deferredResultId if the call did not complete. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + invokeFunction(pageId: string, fnDef: string, args?: unknown[], timeoutMs?: number): 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. + * Continue waiting for a previously deferred function invocation. + * + * @param deferredResultId The ID returned from a timed-out {@link invokeFunction} call. + * @param timeoutMs Maximum time (in ms) to wait before returning a deferred result again. + * @returns The same shape as {@link invokeFunction}. If the result is still not + * available after the timeout, {@link deferredResultId} is returned again. */ - captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise; + waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise; /** * 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..4bd3276745cab 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,92 @@ // ### (https://github.com/electron/electron/issues/25516) ### // ### ### // ####################################################################### + + // Ctrl/Cmd keybindings that correspond to native editing shortcuts and should be handled by the browser / OS and not forwarded to the workbench. + const nativeCtrlCmdKeybindings = { + mac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z']), + withShift: new Set(['v', 'z']), + }, + nonMac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'home', 'end', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z', 'y']), + withShift: new Set(['v', 'z']), + } + }; + + // 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; + } + + const isNonEditingKey = + event.key === 'Escape' || + /^F\d+$/.test(event.key) || + event.key.startsWith('Audio') || event.key.startsWith('Media') || event.key.startsWith('Browser'); + + // Only forward if there's a command modifier or it's a non-editing key + // (most plain key events should just be handled natively by the browser and not forwarded) + if (!(event.ctrlKey || event.altKey || event.metaKey) && !isNonEditingKey) { + return; + } + + // Never handle plain modifier key presses as keybindings + if (event.key === 'Control' || event.key === 'Shift' || event.key === 'Alt' || event.key === 'Meta') { + return; + } + + const isMac = navigator.platform.indexOf('Mac') >= 0; + + // Alt+Key special character handling (Alt + Numpad keys on Windows/Linux, Alt + any key on Mac) + if (event.altKey && !event.ctrlKey && !event.metaKey) { + if (isMac || /^Numpad\d+$/.test(event.code)) { + return; + } + } + + // Allow native shortcuts to be handled by the browser + const ctrlCmd = isMac ? event.metaKey : event.ctrlKey; + if (ctrlCmd && !event.altKey) { + const key = event.key.toLowerCase(); + const keySetsToCheck = [ + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'].always, + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'][event.shiftKey ? 'withShift' : 'noShift'], + ]; + if (keySetsToCheck.some(set => set.has(key))) { + return; + } + + // Emoji picker on Mac + if (isMac && event.ctrlKey && !event.shiftKey && key === ' ') { + return; + } + } + + // Everything else should be forwarded to the workbench for potential shortcut handling. + event.preventDefault(); + event.stopPropagation(); + 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..23d840d811340 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -4,34 +4,24 @@ *--------------------------------------------------------------------------------------------*/ 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 { CancellationToken } from '../../../base/common/cancellation.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 { IElementData } from '../../browserElements/common/browserElements.js'; +import { BrowserViewElementInspector } from './browserViewElementInspector.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,12 +35,16 @@ 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 readonly _debugger: BrowserViewDebugger; + private readonly _inspector: BrowserViewElementInspector; + private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; + private readonly _consoleLogs: string[] = []; + private readonly _onDidNavigate = this._register(new Emitter()); readonly onDidNavigate: Event = this._onDidNavigate.event; @@ -88,6 +82,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 +98,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,28 +129,35 @@ 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 } }); // Return the webContents so Electron can complete the window.open() call return childView.webContents; - } + }, + + // We want the standard browser behavior as opposed to Electron's default of closing the new window when the parent is closed + outlivesOpener: true }; }); + 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._inspector = this._register(new BrowserViewElementInspector(this)); this.setupEventListeners(); } @@ -179,6 +180,9 @@ export class BrowserView extends Disposable implements ICDPTarget { for (const url of favicons) { if (!this._faviconRequestCache.has(url)) { this._faviconRequestCache.set(url, (async () => { + if (url.startsWith('data:image/')) { + return url; + } const response = await webContents.session.fetch(url, { cache: 'force-cache' }); @@ -186,6 +190,9 @@ export class BrowserView extends Disposable implements ICDPTarget { throw new Error(`Failed to fetch favicon: ${response.status} ${response.statusText}`); } const type = await response.headers.get('content-type'); + if (!type?.startsWith('image/')) { + throw new Error(`Favicon is not an image: ${type}`); + } const buffer = await response.arrayBuffer(); return `data:${type};base64,${Buffer.from(buffer).toString('base64')}`; @@ -215,11 +222,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) }); }; @@ -230,7 +239,11 @@ export class BrowserView extends Disposable implements ICDPTarget { // Loading state events webContents.on('did-start-loading', () => { this._lastError = undefined; - fireLoadingEvent(true); + + // Don't fire loading events for e.g. same-document navigations + if (webContents.isLoadingMainFrame()) { + fireLoadingEvent(true); + } }); webContents.on('did-stop-loading', () => fireLoadingEvent(false)); webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => { @@ -244,7 +257,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 +267,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 +290,18 @@ export class BrowserView extends Disposable implements ICDPTarget { webContents.on('did-navigate', fireNavigationEvent); webContents.on('did-navigate-in-page', fireNavigationEvent); + webContents.on('did-navigate', () => { + // 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. + this._consoleLogs.length = 0; // Clear console logs on navigation since they are per-page + this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); + + // Enable pinch-to-zoom + void this._view.webContents.setVisualZoomLevelLimits(1, 3).catch(error => { + this.logService.error('Failed to set visual zoom level limits for browser view webContents.', error); + }); + }); + // Focus events webContents.on('focus', () => { this._onDidChangeFocus.fire({ focused: true }); @@ -281,13 +311,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. @@ -320,6 +378,14 @@ export class BrowserView extends Disposable implements ICDPTarget { finalUpdate: result.finalUpdate }); }); + + // Capture console messages for sharing with chat + this._view.webContents.on('console-message', (event) => { + this._consoleLogs.push(`[${event.level}] ${event.message}`); + if (this._consoleLogs.length > BrowserView.MAX_CONSOLE_LOG_ENTRIES) { + this._consoleLogs.splice(0, this._consoleLogs.length - BrowserView.MAX_CONSOLE_LOG_ENTRIES); + } + }); } private consumePopupPermission(location: BrowserNewPageLocation): boolean { @@ -347,8 +413,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 +427,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 +445,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 +453,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 +462,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 */ @@ -409,6 +485,29 @@ export class BrowserView extends Disposable implements ICDPTarget { this._onDidChangeVisibility.fire({ visible }); } + /** + * Get captured console logs. + */ + getConsoleLogs(): string { + return this._consoleLogs.join('\n'); + } + + /** + * Start element inspection mode. Sets up a CDP overlay that highlights elements + * on hover. When the user clicks, the element data is returned and the overlay is removed. + * @param token Cancellation token to abort the inspection. + */ + async getElementData(token: CancellationToken): Promise { + return this._inspector.getElementData(token); + } + + /** + * Get element data for the currently focused element. + */ + async getFocusedElementData(): Promise { + return this._inspector.getFocusedElementData(); + } + /** * Load a URL in this view */ @@ -444,8 +543,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(); + } } /** @@ -467,55 +570,29 @@ 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 + if (options?.pageRect) { + const zoomFactor = this._view.webContents.getZoomFactor(); + // The visual viewport scale accounts for pinch-to-zoom magnification, which is separate from the regular zoom factor. + const visualViewportScale = await this._inspector.getVisualViewportScale(); + options.screenRect = { + x: options.pageRect.x * visualViewportScale * zoomFactor, + y: options.pageRect.y * visualViewportScale * zoomFactor, + width: options.pageRect.width * visualViewportScale * zoomFactor, + height: options.pageRect.height * visualViewportScale * zoomFactor + }; + } + const image = await this._view.webContents.capturePage(options?.screenRect, { + stayHidden: true }); const buffer = image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view - if (!options?.rect) { + if (!options?.screenRect) { this._lastScreenshot = screenshot; } 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 +643,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 +669,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 +718,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; + if (!this._view.webContents.isDestroyed()) { + this._view.webContents.close({ waitForBeforeUnload: 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; - } - - // 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 +737,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..a2379fccf2810 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) { @@ -152,7 +163,7 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { * Detach from the Electron debugger */ private detachElectronDebugger(): void { - if (!this._electronDebugger.isAttached()) { + if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { return; } @@ -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/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts new file mode 100644 index 0000000000000..16533387380d6 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -0,0 +1,416 @@ +/*--------------------------------------------------------------------------------------------- + * 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, DisposableStore } from '../../../base/common/lifecycle.js'; +import { IElementData, IElementAncestor } from '../../browserElements/common/browserElements.js'; +import { ICDPConnection } from '../common/cdp/types.js'; +import type { BrowserView } from './browserView.js'; + +type Quad = [number, number, number, number, number, number, number, number]; + +interface IBoxModel { + content: Quad; + padding: Quad; + border: Quad; + margin: Quad; + width: number; + height: number; +} + +interface ICSSStyle { + cssText?: string; + cssProperties: Array<{ name: string; value: string }>; +} + +interface ISelectorList { + selectors: Array<{ text: string }>; +} + +interface ICSSRule { + selectorList: ISelectorList; + origin: string; + style: ICSSStyle; +} + +interface IRuleMatch { + rule: ICSSRule; +} + +interface IInheritedStyleEntry { + inlineStyle?: ICSSStyle; + matchedCSSRules: IRuleMatch[]; +} + +interface IMatchedStyles { + inlineStyle?: ICSSStyle; + matchedCSSRules?: IRuleMatch[]; + inherited?: IInheritedStyleEntry[]; +} + +interface INode { + nodeId: number; + backendNodeId: number; + parentId?: number; + localName: string; + attributes: string[]; + children?: INode[]; + pseudoElements?: INode[]; +} + +interface ILayoutMetricsResult { + cssVisualViewport?: { + scale?: number; + }; +} + +function useScopedDisposal() { + const store = new DisposableStore() as DisposableStore & { [Symbol.dispose](): void }; + store[Symbol.dispose] = () => store.dispose(); + return store; +} + +/** + * Manages element inspection on a browser view. + * + * Attaches a persistent CDP session in the constructor; methods wait for + * it to be ready before issuing commands. + */ +export class BrowserViewElementInspector extends Disposable { + + private readonly _connectionPromise: Promise; + + constructor(browser: BrowserView) { + super(); + + this._connectionPromise = browser.attach().then( + async conn => { + try { + // Important: don't use `Runtime.*` commands so we can support inspection during debugging. + // We also initialize here rather than during selection as CSS.enable will hang if debugging is paused, but works if enabled beforehand. + await conn.sendCommand('DOM.enable'); + await conn.sendCommand('Overlay.enable'); + await conn.sendCommand('CSS.enable'); + + if (this._store.isDisposed) { + conn.dispose(); + throw new Error('Inspector disposed before connection was ready'); + } + this._register(conn); + return conn; + } catch (error) { + conn.dispose(); + throw error; + } + } + ); + } + + /** + * Start element inspection mode on the browser view. Sets up an + * overlay that highlights elements on hover. When the user clicks, the + * element data is returned and the overlay is removed. + * + * @param token Cancellation token to abort the inspection. + */ + async getElementData(token: CancellationToken): Promise { + const connection = await this._connectionPromise; + const store = new DisposableStore(); + const result = new Promise((resolve, reject) => { + store.add(token.onCancellationRequested(() => { + resolve(undefined); + })); + + store.add(connection.onEvent(async (event) => { + if (event.method !== 'Overlay.inspectNodeRequested') { + return; + } + + const params = event.params as { backendNodeId: number }; + if (!params?.backendNodeId) { + reject(new Error('Missing backendNodeId in inspectNodeRequested event')); + return; + } + + try { + const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); + resolve(nodeData); + } catch (err) { + reject(err); + } + })); + }); + + try { + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'searchForNode', + highlightConfig: inspectHighlightConfig, + }); + return await result; + } finally { + try { + await connection.sendCommand('Overlay.setInspectMode', { + mode: 'none', + highlightConfig: { showInfo: false, showStyles: false } + }); + await connection.sendCommand('Overlay.hideHighlight'); + } catch { + // Best effort cleanup + } + store.dispose(); + } + } + + /** + * Get element data for the currently focused element. + */ + async getFocusedElementData(): Promise { + const connection = await this._connectionPromise; + + await connection.sendCommand('Runtime.enable'); + const { result } = await connection.sendCommand('Runtime.evaluate', { + expression: 'document.activeElement', + returnByValue: false, + }) as { result: { objectId?: string } }; + + if (!result?.objectId) { + return undefined; + } + + return extractNodeData(connection, { objectId: result.objectId }); + } + + async getVisualViewportScale(): Promise { + try { + const connection = await this._connectionPromise; + const result = await connection.sendCommand('Page.getLayoutMetrics') as ILayoutMetricsResult; + if (typeof result.cssVisualViewport?.scale === 'number') { + const scale = Number(result.cssVisualViewport.scale); + if (Number.isFinite(scale) && scale > 0) { + return scale; + } + } + } catch { + // Ignore execution errors while loading and use defaults. + } + + return 1; + } +} + +async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise { + using store = useScopedDisposal(); + + const discoveredNodesByNodeId: Record = {}; + store.add(connection.onEvent(event => { + if (event.method === 'DOM.setChildNodes') { + const { nodes } = event.params as { nodes: INode[] }; + for (const node of nodes) { + discoveredNodesByNodeId[node.nodeId] = node; + if (node.children) { + for (const child of node.children) { + discoveredNodesByNodeId[child.nodeId] = { + ...child, + parentId: node.nodeId + }; + } + } + if (node.pseudoElements) { + for (const pseudo of node.pseudoElements) { + discoveredNodesByNodeId[pseudo.nodeId] = { + ...pseudo, + parentId: node.nodeId + }; + } + } + } + } + })); + + await connection.sendCommand('DOM.getDocument'); + + const { node } = await connection.sendCommand('DOM.describeNode', id) as { node: INode }; + if (!node) { + throw new Error('Failed to describe node.'); + } + let nodeId = node.nodeId; + if (!nodeId) { + const { nodeIds } = await connection.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [node.backendNodeId] }) as { nodeIds: number[] }; + if (!nodeIds?.length) { + throw new Error('Failed to get node ID.'); + } + nodeId = nodeIds[0]; + } + + const { model } = await connection.sendCommand('DOM.getBoxModel', { nodeId }) as { model: IBoxModel }; + if (!model) { + throw new Error('Failed to get box model.'); + } + + const content = model.content; + const margin = model.margin; + const x = Math.min(margin[0], content[0]); + const y = Math.min(margin[1], content[1]); + const width = Math.max(margin[2] - margin[0], content[2] - content[0]); + const height = Math.max(margin[5] - margin[1], content[5] - content[1]); + + const matched = await connection.sendCommand('CSS.getMatchedStylesForNode', { nodeId }); + if (!matched) { + throw new Error('Failed to get matched css.'); + } + + const computedStyle = formatMatchedStyles(matched as IMatchedStyles); + const { outerHTML } = await connection.sendCommand('DOM.getOuterHTML', { nodeId }) as { outerHTML: string }; + if (!outerHTML) { + throw new Error('Failed to get outerHTML.'); + } + + const attributes = attributeArrayToRecord(node.attributes); + + const ancestors: IElementAncestor[] = []; + let currentNode: INode | undefined = discoveredNodesByNodeId[nodeId] ?? node; + while (currentNode) { + const attributes = attributeArrayToRecord(currentNode.attributes); + ancestors.unshift({ + tagName: currentNode.localName, + id: attributes.id, + classNames: attributes.class?.trim().split(/\s+/).filter(Boolean) + }); + currentNode = currentNode.parentId ? discoveredNodesByNodeId[currentNode.parentId] : undefined; + } + + let computedStyles: Record | undefined; + try { + const { computedStyle: computedStyleArray } = await connection.sendCommand('CSS.getComputedStyleForNode', { nodeId }) as { computedStyle?: Array<{ name: string; value: string }> }; + if (computedStyleArray) { + computedStyles = {}; + for (const prop of computedStyleArray) { + if (prop.name && typeof prop.value === 'string') { + computedStyles[prop.name] = prop.value; + } + } + } + } catch { } + + return { + outerHTML, + computedStyle, + bounds: { x, y, width, height }, + ancestors, + attributes, + computedStyles, + dimensions: { top: y, left: x, width, height } + }; +} + +function formatMatchedStyles(matched: IMatchedStyles): string { + const lines: string[] = []; + + if (matched.inlineStyle?.cssProperties?.length) { + lines.push('/* Inline style */'); + lines.push('element {'); + for (const prop of matched.inlineStyle.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + + if (matched.matchedCSSRules?.length) { + for (const ruleEntry of matched.matchedCSSRules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); + lines.push(`/* Matched Rule from ${rule.origin} */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + } + + if (matched.inherited?.length) { + let level = 1; + for (const inherited of matched.inherited) { + if (inherited.inlineStyle) { + lines.push(`/* Inherited from ancestor level ${level} (inline) */`); + lines.push('element {'); + lines.push(inherited.inlineStyle.cssText || ''); + lines.push('}\n'); + } + + const rules = inherited.matchedCSSRules || []; + for (const ruleEntry of rules) { + const rule = ruleEntry.rule; + const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', '); + lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`); + lines.push(`${selectors} {`); + for (const prop of rule.style.cssProperties) { + if (prop.name && prop.value) { + lines.push(` ${prop.name}: ${prop.value};`); + } + } + lines.push('}\n'); + } + level++; + } + } + + return '\n' + lines.join('\n'); +} + +function attributeArrayToRecord(attributes: string[]): Record { + const record: Record = {}; + for (let i = 0; i < attributes.length; i += 2) { + const name = attributes[i]; + const value = attributes[i + 1]; + record[name] = value; + } + return record; +} + +/** Slightly customised CDP debugger inspect highlight colours. */ +const inspectHighlightConfig = { + showInfo: true, + showRulers: false, + showStyles: true, + showAccessibilityInfo: true, + showExtensionLines: false, + contrastAlgorithm: 'aa', + contentColor: { r: 173, g: 216, b: 255, a: 0.8 }, + paddingColor: { r: 150, g: 200, b: 255, a: 0.5 }, + borderColor: { r: 120, g: 180, b: 255, a: 0.7 }, + marginColor: { r: 200, g: 220, b: 255, a: 0.4 }, + eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeColor: { r: 130, g: 160, b: 255, a: 0.8 }, + shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 }, + gridHighlightConfig: { + rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 }, + columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, + rowLineColor: { r: 120, g: 180, b: 255 }, + columnLineColor: { r: 120, g: 180, b: 255 }, + rowLineDash: true, + columnLineDash: true + }, + flexContainerHighlightConfig: { + containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' }, + mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } }, + }, + flexItemHighlightConfig: { + baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } }, + baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' }, + flexibilityArrow: { color: { r: 130, g: 190, b: 255 } } + }, +}; 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..150bfd6b6b47e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,8 +6,10 @@ 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 { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { IElementData } from '../../browserElements/common/browserElements.js'; +import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { BrowserView } from './browserView.js'; @@ -16,9 +18,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 { 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'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -40,6 +48,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); + private readonly _activeTokens = new Map(); + private _keybindings: { [commandId: string]: string } = Object.create(null); // ICDPBrowserTarget events private readonly _onTargetCreated = this._register(new Emitter()); @@ -53,38 +63,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 +143,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); + async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; - // Create the browser view (fires onTargetCreated) - const view = this.createBrowserView(targetId, browserSession); - - 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 +197,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa await this.destroyBrowserView(view.id); } } - - browserSession.dispose(); } /** @@ -278,6 +254,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 +286,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 +306,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 +326,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 +349,214 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa workspaceId, this.environmentMainService.workspaceStorageHome ); - await browserSession.electronSession.clearData(); + browserSession.connectStorage(this.applicationStorageMainService); + await browserSession.clearData(); + } + + async getConsoleLogs(id: string): Promise { + return this._getBrowserView(id).getConsoleLogs(); + } + + async getElementData(id: string, cancellationId: number): Promise { + return this._makeCancellable(cancellationId, (token) => this._getBrowserView(id).getElementData(token)); + } + + async getFocusedElementData(id: string): Promise { + return this._getBrowserView(id).getFocusedElementData(); + } + + async cancel(cancellationId: number): Promise { + this._activeTokens.get(cancellationId)?.cancel(); + } + + async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { + this._keybindings = keybindings; + } + + private async _makeCancellable(cancellationId: number, callback: (token: CancellationToken) => T | Promise): Promise { + const cts: CancellationTokenSource = new CancellationTokenSource(); + this._activeTokens.set(cancellationId, cts); + try { + return await callback(cts.token); + } finally { + this._activeTokens.delete(cancellationId); + cts.dispose(); + } + } + + /** + * 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..91f512d00634e 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -3,19 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { DeferredPromise } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; -import { IPlaywrightService } from '../common/playwrightService.js'; +import { IInvokeFunctionResult, 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 { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { generateUuid } from '../../../base/common/uuid.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; + } +} + +const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -32,9 +48,16 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _browser: Browser | undefined; private _initPromise: Promise | undefined; + /** In-flight deferred results keyed by their generated ID. */ + private readonly _deferredResults = this._register(new DisposableMap; + } & IDisposable>()); + 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 +100,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,44 +157,96 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.getSummary(pageId, true); } - async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { + 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)); + } + + private async invokeFunctionWithDeferral(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs); + } + + async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); + if (timeoutMs !== undefined) { + return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs); + } + + let result, error; try { - await this.initialize(); + result = await this.invokeFunctionRaw(pageId, fnDef, ...args); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + } - const vm = await import('vm'); - const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + const summary = await this._pages.getSummary(pageId); - let result; - try { - result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args)); - } catch (err: unknown) { - result = err instanceof Error ? err.message : String(err); - } + return { result, error, summary }; + } - let summary; - try { - summary = await this._pages.getSummary(pageId); - } catch (err: unknown) { - summary = err instanceof Error ? err.message : String(err); - } - return { result, summary }; - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightService] Script execution failed:', errorMessage); - throw err; + async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise { + const entry = this._deferredResults.get(deferredResultId); + if (!entry) { + throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); } + + const { pageId, promise } = entry; + // Remove eagerly — _runWithDeferral will re-insert if interrupted again. + this._deferredResults.deleteAndDispose(deferredResultId); + + // The callback ignores the page param since execution is already in-flight. + return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId); } - 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); + /** + * Run a callback against a page with deferred result support. + */ + private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string): Promise { + const effectiveTimeout = timeoutMs; + + // Start execution via safeRunAgainstPage, but capture the raw promise + // independently so it can be deferred if a dialog or timeout interrupts. + const deferred = new DeferredPromise(); + const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => { + const promise = callback(page); + promise.catch(() => { /* prevent unhandled rejection if deferred */ }); + deferred.settleWith(promise); + return promise; }); + + let result, error; + let interrupted = false; + + try { + result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; }); + } catch (err: unknown) { + if (err instanceof DialogInterruptedError) { + interrupted = true; + } + error = err instanceof Error ? err.message : String(err); + } + + let deferredResultId: string | undefined; + if (interrupted) { + deferredResultId = existingDeferredId ?? generateUuid(); + const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); + this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() }); + + this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`); + } + + const summary = await this._pages.getSummary(pageId); + return { result, error, summary, deferredResultId }; } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { @@ -407,21 +491,15 @@ class PlaywrightPageManager extends Disposable { try { await this._group!.addView(viewId); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageManager] Failed to add view:', errorMessage); + } catch (err) { this.onViewRemoved(viewId); + throw err; } } private async _removePageFromGroup(viewId: string): Promise { this.onViewRemoved(viewId); - try { - await this._group!.removeView(viewId); - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightPageManager] Failed to remove view:', errorMessage); - } + await this._group!.removeView(viewId); } private _fireTrackedPagesChanged(): void { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 231daf0fba047..ceddf207a6e78 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -9,10 +9,24 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; +type IAiAriaSnapshotOptions = NonNullable[0]> & { _track?: string }; + declare module 'playwright-core' { interface Page { - // A hidden Playwright method that returns an AI-friendly snapshot of the page. - _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; + // We defined this here to be able to use the unofficial `_track` option + ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise; + } +} + +/** + * Thrown when a dialog (alert, confirm, prompt) opens while a page action is + * running. The caller should defer the underlying promise and let the agent + * handle the dialog before retrying. + */ +export class DialogInterruptedError extends Error { + constructor() { + super('Action was interrupted by a dialog'); + this.name = 'DialogInterruptedError'; } } @@ -42,7 +56,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 +83,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 +103,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 +131,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,14 +147,26 @@ 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(() => { if (!actionDidComplete) { // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. - throw new Error('Action was interrupted by a dialog'); + throw new DialogInterruptedError(); } return result!; }); @@ -150,7 +179,7 @@ export class PlaywrightTab { this._needsFullSnapshot = false; } - const snapshotFromPage = await this.safeRunAgainstPage((page) => page._snapshotForAI({ track: 'response' })).catch(() => { + const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => { this._needsFullSnapshot = true; return undefined; }); @@ -159,7 +188,7 @@ export class PlaywrightTab { const logs = this._logs; this._logs = []; - const snapshot = (full ? snapshotFromPage?.full : snapshotFromPage?.incremental ?? snapshotFromPage?.full)?.trim() ?? ''; + const snapshot = snapshotFromPage?.trim() ?? ''; return [ ...(title ? [`Page Title: ${title}`] : []), @@ -170,10 +199,18 @@ export class PlaywrightTab { `Recent events:`, ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) ] : []), - ...(snapshot ? ['Snapshot:', snapshot] : []) + `Snapshot: ${snapshotFromPage ? snapshot ? `\n${snapshot}` : '' : ''}`, ].join('\n'); } + private getAiSnapshot(page: playwright.Page, full: boolean): Promise { + const options: IAiAriaSnapshotOptions = { mode: 'ai' }; + if (!full) { + options._track = 'response'; + } + return page.ariaSnapshot(options); + } + private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { const requests: playwright.Request[] = []; 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/dataChannel/browser/forwardingTelemetryService.ts b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts index e27a067eaf853..9150e178e99b3 100644 --- a/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts +++ b/src/vs/platform/dataChannel/browser/forwardingTelemetryService.ts @@ -70,6 +70,10 @@ export class InterceptingTelemetryService implements ITelemetryService { setExperimentProperty(name: string, value: string): void { this._baseService.setExperimentProperty(name, value); } + + setCommonProperty(name: string, value: string): void { + this._baseService.setCommonProperty(name, value); + } } export interface IEditTelemetryData { 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..8254b6131f237 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { equals } from '../../../base/common/arrays.js'; +import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; @@ -329,11 +330,64 @@ export interface IEditorOptions { export interface IModalEditorPartOptions { + /** + * Whether the modal editor should be maximized. + */ + readonly maximized?: boolean; + + /** + * 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. */ readonly navigation?: IModalEditorNavigation; + + /** + * Optional sidebar content to render on the left side of the + * modal editor. The caller provides a render callback that + * receives a container element and a layout callback, and + * returns a disposable to clean up when the modal closes. + * + * Note: the sidebar will only be shown when provided during + * opening and cannot currently be added, removed, or updated + * after the modal editor is opened. + */ + readonly sidebar?: IModalEditorSidebar; +} + +/** + * Modal sidebar supports rendering custom content in a sidebar next to the main editor content. + */ +export interface IModalEditorSidebar { + + /** + * Sidebar width set by the user via resizing, if any. + */ + readonly sidebarWidth?: number; + + /** + * Whether the sidebar is hidden. + */ + readonly sidebarHidden?: boolean; + + /** + * Render the sidebar content into the given container. + * + * @param container The DOM element to render into. + * @param onDidLayout An event that fires when the sidebar is + * laid out with the available dimensions. + * @returns A disposable to clean up when the modal closes. + */ + readonly render: (container: unknown /* HTMLElement */, onDidLayout: Event<{ readonly height: number; readonly width: number }>) => IDisposable; } /** diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 45cb23ba6f3f7..69d15c9530703 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[]; @@ -53,7 +54,7 @@ export interface NativeParsedArgs { goto?: boolean; 'new-window'?: boolean; 'reuse-window'?: boolean; - 'sessions'?: boolean; + 'agents'?: boolean; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; @@ -74,6 +75,7 @@ export interface NativeParsedArgs { 'extensions-dir'?: string; 'extensions-download-dir'?: string; 'builtin-extensions-dir'?: string; + 'agent-plugins-dir'?: string; extensionDevelopmentPath?: string[]; // undefined or array of 1 or more local paths or URIs extensionTestsPath?: string; // either a local path or a URI extensionDevelopmentKind?: string[]; @@ -86,6 +88,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 +111,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 +126,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..718f2cc83c4b5 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -147,6 +147,26 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return joinPath(this.userHome, this.productService.dataFolderName, 'extensions').fsPath; } + @memoize + get agentPluginsPath(): string { + const cliAgentPluginsDir = this.args['agent-plugins-dir']; + if (cliAgentPluginsDir) { + return resolve(cliAgentPluginsDir); + } + + const vscodeAgentPlugins = env['VSCODE_AGENT_PLUGINS']; + if (vscodeAgentPlugins) { + return vscodeAgentPlugins; + } + + const vscodePortable = env['VSCODE_PORTABLE']; + if (vscodePortable) { + return join(vscodePortable, 'agent-plugins'); + } + + return joinPath(this.userHome, this.productService.dataFolderName, 'agent-plugins').fsPath; + } + @memoize get extensionDevelopmentLocationURI(): URI[] | undefined { const extensionDevelopmentPaths = this.args.extensionDevelopmentPath; @@ -264,6 +284,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..0a78893bfc13e 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.', @@ -99,7 +108,7 @@ export const OPTIONS: OptionDescriptions> = { 'goto': { type: 'boolean', cat: 'o', alias: 'g', args: 'file:line[:character]', description: localize('goto', "Open a file at the path on the specified line and character position.") }, 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, - 'sessions': { type: 'boolean', cat: 'o', description: localize('sessions', "Opens the sessions window.") }, + 'agents': { type: 'boolean', cat: 'o', deprecates: ['sessions'], description: localize('agents', "Opens the agents window.") }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, @@ -111,6 +120,7 @@ export const OPTIONS: OptionDescriptions> = { 'extensions-download-dir': { type: 'string' }, 'builtin-extensions-dir': { type: 'string' }, 'list-extensions': { type: 'boolean', cat: 'e', description: localize('listExtensions', "List the installed extensions.") }, + 'agent-plugins-dir': { type: 'string' }, 'show-versions': { type: 'boolean', cat: 'e', description: localize('showVersions', "Show versions of installed extensions, when using --list-extensions.") }, 'category': { type: 'string', allowEmptyValue: true, cat: 'e', description: localize('category', "Filters installed extensions by provided category, when using --list-extensions."), args: 'category' }, 'install-extension': { type: 'string[]', cat: 'e', args: 'ext-id | path', description: localize('installExtension', "Installs or updates an extension. The argument is either an extension id or a path to a VSIX. The identifier of an extension is '${publisher}.${name}'. Use '--force' argument to update to latest version. To install a specific version provide '@${version}'. For example: 'vscode.csharp@1.2.3'.") }, @@ -158,14 +168,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/environment/node/userDataPath.ts b/src/vs/platform/environment/node/userDataPath.ts index 9c6361e341acf..3082ef58ad064 100644 --- a/src/vs/platform/environment/node/userDataPath.ts +++ b/src/vs/platform/environment/node/userDataPath.ts @@ -47,7 +47,7 @@ function doGetUserDataPath(cliArgs: NativeParsedArgs, productName: string): stri // 0. Running out of sources has a fixed productName if (process.env['VSCODE_DEV']) { if ((process as INodeProcess).isEmbeddedApp) { - productName = 'sessions-oss-dev'; + productName = 'agents-oss-dev'; } else { productName = 'code-oss-dev'; } 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..5cbe6159c652f 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', @@ -76,6 +79,9 @@ const _allApiProposals = { chatReferenceDiagnostic: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatReferenceDiagnostic.d.ts', }, + chatSessionCustomizationProvider: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts', + }, chatSessionsProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts', version: 3 @@ -314,6 +320,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 +433,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 +490,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/git/common/localGitService.ts b/src/vs/platform/git/common/localGitService.ts new file mode 100644 index 0000000000000..a0260a9d6bcb4 --- /dev/null +++ b/src/vs/platform/git/common/localGitService.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ILocalGitService = createDecorator('localGitService'); + +/** + * Low-level service for executing git commands on the local machine. + * Used in the shared process where Node.js APIs are available. + * All path arguments are native file-system paths. + */ +export interface ILocalGitService { + readonly _serviceBrand: undefined; + + clone(operationId: string, cloneUrl: string, targetPath: string, ref?: string): Promise; + pull(operationId: string, repoPath: string): Promise; + checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise; + revParse(repoPath: string, ref: string): Promise; + fetch(operationId: string, repoPath: string): Promise; + revListCount(repoPath: string, fromRef: string, toRef: string): Promise; + cancel(operationId: string): Promise; +} diff --git a/src/vs/platform/git/node/localGitService.ts b/src/vs/platform/git/node/localGitService.ts new file mode 100644 index 0000000000000..b7b3f9dd82c0b --- /dev/null +++ b/src/vs/platform/git/node/localGitService.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationError } from '../../../base/common/errors.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILocalGitService } from '../common/localGitService.js'; +import { ILogService } from '../../log/common/log.js'; + +export class LocalGitService implements ILocalGitService { + declare readonly _serviceBrand: undefined; + + private _runningProcesses = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { } + + private _exec(operationId: string, args: string[], cwd?: string): Promise { + return new Promise((resolve, reject) => { + this._logService.trace(`[LocalGitService] git ${args.join(' ')}${cwd ? ` (cwd: ${cwd})` : ''}`); + const proc = cp.execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => { + if (!this._runningProcesses.delete(operationId)) { + reject(new CancellationError()); + return; + } + if (err) { + this._logService.error(`[LocalGitService] git ${args[0]} failed:`, err.message, stderr); + reject(err); + return; + } + resolve(stdout); + }); + + this._runningProcesses.set(operationId, proc); + }); + } + + async clone(operationId: string, cloneUrl: string, targetPath: string, ref?: string): Promise { + const args = ['clone']; + if (ref) { + args.push('--branch', ref); + } + args.push('--', cloneUrl, targetPath); + await this._exec(operationId, args); + } + + async pull(operationId: string, repoPath: string): Promise { + const before = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim(); + await this._exec(operationId, ['pull', '--ff-only'], repoPath); + const after = (await this._exec(operationId, ['rev-parse', 'HEAD'], repoPath)).trim(); + return before !== after; + } + + async checkout(operationId: string, repoPath: string, treeish: string, detached?: boolean): Promise { + const args = detached + ? ['checkout', '--detach', treeish] + : ['checkout', treeish]; + await this._exec(operationId, args, repoPath); + } + + async revParse(repoPath: string, ref: string): Promise { + return (await this._exec(generateUuid(), ['rev-parse', ref], repoPath)).trim(); + } + + async fetch(operationId: string, repoPath: string): Promise { + await this._exec(operationId, ['fetch'], repoPath); + } + + async revListCount(repoPath: string, fromRef: string, toRef: string): Promise { + const result = await this._exec(generateUuid(), ['rev-list', '--count', `${fromRef}..${toRef}`], repoPath); + return Number(result.trim()) || 0; + } + + async cancel(operationId: string): Promise { + const proc = this._runningProcesses.get(operationId); + if (proc) { + this._runningProcesses.delete(operationId); + proc.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..b45f24b688911 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); } + // Agents window + else if (args['agents'] && this.productService.quality !== 'stable') { + usedWindows = await this.windowsMainService.openAgentsWindow(baseConfig); + } + // 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/native/common/native.ts b/src/vs/platform/native/common/native.ts index aa73a7c63c437..3e47d272d003e 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -129,7 +129,7 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; - openSessionsWindow(): Promise; + openAgentsWindow(): Promise; isFullScreen(options?: INativeHostOptions): Promise; toggleFullScreen(options?: INativeHostOptions): Promise; @@ -242,6 +242,7 @@ export interface ICommonNativeHostService { // Perf Introspection profileRenderer(session: string, duration: number): Promise; + startTracing(categories: string): Promise; // Connectivity resolveProxy(url: string): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 3edc2ef195d9a..19620e7bf4d9f 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -304,10 +304,11 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } - async openSessionsWindow(windowId: number | undefined): Promise { - await this.windowsMainService.openSessionsWindow({ + async openAgentsWindow(windowId: number | undefined): Promise { + await this.windowsMainService.openAgentsWindow({ context: OpenContext.API, contextWindowId: windowId, + cli: this.environmentMainService.args }); } @@ -1158,11 +1159,29 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } + private _isTracing = false; + + async startTracing(windowId: number | undefined, categories: string): Promise { + if (this._isTracing) { + throw new Error(localize('tracing.alreadyInProgress', 'A tracing session is already in progress. Use command `"{0}"` to stop it first.', 'workbench.action.stopTracing')); + } + + const traceOptions = ['record-until-full', 'enable-sampling']; + + await contentTracing.startRecording({ + categoryFilter: categories, + traceOptions: traceOptions.join(',') + }); + this._isTracing = true; + } + async stopTracing(windowId: number | undefined): Promise { - if (!this.environmentMainService.args.trace) { - return; // requires tracing to be on + if (!this._isTracing && !this.environmentMainService.args.trace) { + return; // no tracing in progress } + this._isTracing = false; + const path = await contentTracing.stopRecording(`${randomPath(this.environmentMainService.userHome.fsPath, this.productService.applicationName)}.trace.txt`); // Inform user to report an issue 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..d4fe41fbde05a 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -16,7 +16,7 @@ import Severity from '../../../base/common/severity.js'; import { isString } from '../../../base/common/types.js'; import { isModifierKey } from '../../../base/common/keyCodes.js'; import { localize } from '../../../nls.js'; -import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput, QuickPickFocus, QuickInputType, IQuickTree, IQuickTreeItem } from '../common/quickInput.js'; +import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInput, IQuickInputButton, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickWidget, QuickInputHideReason, QuickPickInput, QuickPickFocus, QuickInputType, IQuickTree, IQuickTreeItem, QuickInputAlignment } from '../common/quickInput.js'; import { QuickInputBox } from './quickInputBox.js'; import { QuickInputUI, Writeable, IQuickInputStyles, IQuickInputOptions, QuickPick, backButton, InputBox, Visibilities, QuickWidget, InQuickInputContextKey, QuickInputTypeContextKey, EndOfQuickInputBoxContextKey, QuickInputAlignmentContextKey } from './quickInput.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; @@ -26,7 +26,7 @@ import { IContextMenuService } from '../../contextview/browser/contextView.js'; import { QuickInputList } from './quickInputList.js'; import { IContextKey, IContextKeyService } from '../../contextkey/common/contextkey.js'; import './quickInputActions.js'; -import { autorun, observableValue } from '../../../base/common/observable.js'; +import { IObservable, autorun, observableValue } from '../../../base/common/observable.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; @@ -38,7 +38,7 @@ import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; -import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; +import { getAnchorRect, IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; const $ = dom.$; @@ -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; } @@ -81,6 +81,9 @@ export class QuickInputController extends Disposable { private viewState: QuickInputViewState | undefined; private dndController: QuickInputDragAndDropController | undefined; + private readonly _alignment = observableValue(this, 'top'); + readonly alignment: IObservable = this._alignment; + private readonly inQuickInputContext: IContextKey; private readonly quickInputTypeContext: IContextKey; private readonly endOfQuickInputBoxContext: IContextKey; @@ -120,6 +123,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]) { @@ -400,6 +404,11 @@ export class QuickInputController extends Disposable { } })); + // Mirror DnD alignment into the stable observable + this._register(autorun(reader => { + this._alignment.set(this.dndController!.alignment.read(reader), undefined); + })); + this.ui = { container, styleSheet, @@ -655,6 +664,9 @@ export class QuickInputController extends Disposable { } setAlignment(alignment: 'top' | 'center' | { top: number; left: number }): void { + if (this.controller?.anchor) { + return; // anchored inputs own their own positioning + } this.dndController?.setAlignment(alignment); } @@ -670,7 +682,6 @@ export class QuickInputController extends Disposable { private show(controller: IQuickInput) { const ui = this.getUI(true); - this.onShowEmitter.fire(); const oldController = this.controller; this.controller = controller; oldController?.didHide(); @@ -715,6 +726,15 @@ export class QuickInputController extends Disposable { this.updateLayout(); this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); + if (controller.anchor) { + // Anchored quick inputs are positioned near a specific element, not + // at the default top location, so report them as custom-positioned. + this._alignment.set('custom', undefined); + } else { + // Re-sync from DnD in case a previous anchored input left us stale. + this._alignment.set(this.dndController?.alignment.get() ?? 'top', undefined); + } + this.onShowEmitter.fire(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); } @@ -837,13 +857,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(); } @@ -873,7 +894,7 @@ export class QuickInputController extends Disposable { // Position if (this.controller?.anchor) { const container = this.layoutService.getContainer(dom.getActiveWindow()).getBoundingClientRect(); - const anchor = getAnchorRect(this.controller.anchor); + const anchor = getAnchorRect(this.controller.anchor as HTMLElement | IAnchor); width = 380; listHeight = this.dimension ? Math.min(this.dimension.height * 0.2, 200) : 200; @@ -901,7 +922,7 @@ export class QuickInputController extends Disposable { style.width = `${width}px`; style.height = ''; } else { - style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; + style.top = `${this.viewState?.top !== undefined ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; style.right = ''; style.bottom = ''; @@ -922,13 +943,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); @@ -1013,7 +1033,9 @@ class QuickInputDragAndDropController extends Disposable { private readonly _controlsOnLeft: boolean; private readonly _controlsOnRight: boolean; - private _quickInputAlignmentContext: IContextKey<'center' | 'top' | undefined>; + private readonly _quickInputAlignmentContext: IContextKey<'center' | 'top' | undefined>; + private readonly _alignment = observableValue(this, 'top'); + readonly alignment: IObservable = this._alignment; constructor( private _container: HTMLElement, @@ -1035,6 +1057,11 @@ class QuickInputDragAndDropController extends Disposable { this._registerLayoutListener(); this.registerMouseListeners(); this.dndViewState.set({ ...initialViewState, done: true }, undefined); + // Initialize alignment from restored state. The exact snap alignment will + // be refined in layoutContainer() once pixel dimensions are available. + if (initialViewState?.top !== undefined && initialViewState?.left !== undefined) { + this._setAlignmentState(undefined); + } } reparentUI(container: HTMLElement): void { @@ -1048,7 +1075,7 @@ class QuickInputDragAndDropController extends Disposable { const state = this.dndViewState.get(); const dragAreaRect = this._quickInputContainer.getBoundingClientRect(); - if (state?.top && state?.left) { + if (state?.top !== undefined && state?.left !== undefined) { const a = Math.round(state.left * 1e2) / 1e2; const b = dimension.width; const c = dragAreaRect.width; @@ -1062,6 +1089,11 @@ class QuickInputDragAndDropController extends Disposable { this._quickInputContainer.classList.toggle('no-drag', !enabled); } + private _setAlignmentState(value: 'top' | 'center' | undefined): void { + this._quickInputAlignmentContext.set(value); + this._alignment.set(value ?? 'custom', undefined); + } + setAlignment(alignment: 'top' | 'center' | { top: number; left: number }, done = true): void { if (alignment === 'top') { this.dndViewState.set({ @@ -1069,17 +1101,17 @@ class QuickInputDragAndDropController extends Disposable { left: (this._getCenterXSnapValue() + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth, done }, undefined); - this._quickInputAlignmentContext.set('top'); + this._setAlignmentState('top'); } else if (alignment === 'center') { this.dndViewState.set({ top: this._getCenterYSnapValue() / this._container.clientHeight, left: (this._getCenterXSnapValue() + (this._quickInputContainer.clientWidth / 2)) / this._container.clientWidth, done }, undefined); - this._quickInputAlignmentContext.set('center'); + this._setAlignmentState('center'); } else { this.dndViewState.set({ top: alignment.top, left: alignment.left, done }, undefined); - this._quickInputAlignmentContext.set(undefined); + this._setAlignmentState(undefined); } } @@ -1108,6 +1140,7 @@ class QuickInputDragAndDropController extends Disposable { } this.dndViewState.set({ top: undefined, left: undefined, done: true }, undefined); + this._setAlignmentState('top'); })); // Mouse down @@ -1189,14 +1222,14 @@ class QuickInputDragAndDropController extends Disposable { this.dndViewState.set({ top, left, done: false }, undefined); if (snappingToCenterX) { if (snappingToTop) { - this._quickInputAlignmentContext.set('top'); + this._setAlignmentState('top'); return; } else if (snappingToCenter) { - this._quickInputAlignmentContext.set('center'); + this._setAlignmentState('center'); return; } } - this._quickInputAlignmentContext.set(undefined); + this._setAlignmentState(undefined); } private _getTopSnapValue() { 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/quickInputService.ts b/src/vs/platform/quickinput/browser/quickInputService.ts index 45bd3c7f9a406..a017e97fa8ff9 100644 --- a/src/vs/platform/quickinput/browser/quickInputService.ts +++ b/src/vs/platform/quickinput/browser/quickInputService.ts @@ -11,7 +11,7 @@ import { ILayoutService } from '../../layout/browser/layoutService.js'; import { IOpenerService } from '../../opener/common/opener.js'; import { QuickAccessController } from './quickAccess.js'; import { IQuickAccessController } from '../common/quickAccess.js'; -import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickInputHideReason, QuickPickInput } from '../common/quickInput.js'; +import { IInputBox, IInputOptions, IKeyMods, IPickOptions, IQuickInputButton, IQuickInputService, IQuickNavigateConfiguration, IQuickPick, IQuickPickItem, IQuickTree, IQuickTreeItem, IQuickWidget, QuickInputAlignment, QuickInputHideReason, QuickPickInput } from '../common/quickInput.js'; import { defaultButtonStyles, defaultCountBadgeStyles, defaultInputBoxStyles, defaultKeybindingLabelStyles, defaultProgressBarStyles, defaultToggleStyles, getListStyles } from '../../theme/browser/defaultStyles.js'; import { activeContrastBorder, asCssVariable, pickerGroupBorder, pickerGroupForeground, quickInputBackground, quickInputForeground, quickInputListFocusBackground, quickInputListFocusForeground, quickInputListFocusIconForeground, quickInputTitleBackground, widgetBorder, widgetShadow } from '../../theme/common/colorRegistry.js'; import { IThemeService, Themable } from '../../theme/common/themeService.js'; @@ -19,6 +19,7 @@ import { IQuickInputOptions, IQuickInputStyles, QuickInputHoverDelegate } from ' import { QuickInputController, IQuickInputControllerHost } from './quickInputController.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { getWindow } from '../../../base/browser/dom.js'; +import { IObservable, autorun, observableValue } from '../../../base/common/observable.js'; export class QuickInputService extends Themable implements IQuickInputService { @@ -26,6 +27,9 @@ export class QuickInputService extends Themable implements IQuickInputService { get backButton(): IQuickInputButton { return this.controller.backButton; } + private readonly _alignment = observableValue(this, 'top'); + readonly alignment: IObservable = this._alignment; + private readonly _onShow = this._register(new Emitter()); readonly onShow = this._onShow.event; @@ -118,6 +122,11 @@ export class QuickInputService extends Themable implements IQuickInputService { this._onHide.fire(); })); + // Mirror alignment from controller + this._register(autorun(reader => { + this._alignment.set(controller.alignment.read(reader), undefined); + })); + return controller; } 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..3dd5d48f517df 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -12,6 +12,7 @@ import { IItemAccessor } from '../../../base/common/fuzzyScorer.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; +import { IObservable } from '../../../base/common/observable.js'; import Severity from '../../../base/common/severity.js'; import { URI } from '../../../base/common/uri.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; @@ -115,9 +116,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[]; @@ -200,7 +206,7 @@ export interface IPickOptions { /** * an optional anchor for the picker */ - anchor?: HTMLElement | { x: number; y: number }; + anchor?: unknown /* HTMLElement */ | { x: number; y: number }; onKeyMods?: (keyMods: IKeyMods) => void; onDidFocus?: (entry: T) => void; @@ -361,7 +367,7 @@ export interface IQuickInput extends IDisposable { /** * An optional anchor for the quick input. */ - anchor?: HTMLElement | { x: number; y: number }; + anchor?: unknown /* HTMLElement */ | { x: number; y: number }; /** * Shows the quick input. @@ -396,7 +402,7 @@ export interface IQuickWidget extends IQuickInput { /** * A HTML element that will be rendered inside the quick input. */ - widget: HTMLElement | undefined; + widget: unknown /* HTMLElement */ | undefined; } export interface IQuickPickWillAcceptEvent { @@ -929,6 +935,8 @@ export const IQuickInputService = createDecorator('quickInpu export type Omit = Pick>; +export type QuickInputAlignment = 'top' | 'center' | 'custom'; + export interface IQuickInputService { readonly _serviceBrand: undefined; @@ -953,6 +961,11 @@ export interface IQuickInputService { */ readonly onHide: Event; + /** + * The current alignment of the quick input widget. + */ + readonly alignment: IObservable; + /** * Opens the quick input box for selecting items and returns a promise * with the user selected item(s) if any. @@ -1172,6 +1185,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/sandbox/browser/sandboxHelperService.ts b/src/vs/platform/sandbox/browser/sandboxHelperService.ts new file mode 100644 index 0000000000000..2506ae2e49fac --- /dev/null +++ b/src/vs/platform/sandbox/browser/sandboxHelperService.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 { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { ISandboxDependencyStatus, ISandboxHelperService } from '../common/sandboxHelperService.js'; + +class NullSandboxHelperService implements ISandboxHelperService { + declare readonly _serviceBrand: undefined; + + async checkSandboxDependencies(): Promise { + // Web targets cannot inspect host sandbox dependencies directly. + // Treat them as satisfied so browser workbench targets do not fail DI + // or block sandbox flows on an unavailable host-side capability. + return { + bubblewrapInstalled: true, + socatInstalled: true, + }; + } +} + +registerSingleton(ISandboxHelperService, NullSandboxHelperService, InstantiationType.Delayed); diff --git a/src/vs/platform/sandbox/common/sandboxHelperIpc.ts b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts new file mode 100644 index 0000000000000..125288dcf6875 --- /dev/null +++ b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ISandboxDependencyStatus, ISandboxHelperService } from './sandboxHelperService.js'; + +export const SANDBOX_HELPER_CHANNEL_NAME = 'sandboxHelper'; + +export class SandboxHelperChannel implements IServerChannel { + + constructor(private readonly service: ISandboxHelperService) { } + + listen(_context: unknown, _event: string): Event { + throw new Error('Invalid listen'); + } + + call(_context: unknown, command: string, _arg?: unknown, _cancellationToken?: CancellationToken): Promise { + switch (command) { + case 'checkSandboxDependencies': + return this.service.checkSandboxDependencies() as Promise; + } + + throw new Error('Invalid call'); + } +} + +export class SandboxHelperChannelClient implements ISandboxHelperService { + declare readonly _serviceBrand: undefined; + + constructor(private readonly channel: IChannel) { } + + checkSandboxDependencies(): Promise { + return this.channel.call('checkSandboxDependencies'); + } +} diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts new file mode 100644 index 0000000000000..5660dd21eb63e --- /dev/null +++ b/src/vs/platform/sandbox/common/sandboxHelperService.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. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ISandboxHelperService = createDecorator('sandboxHelperService'); + +export interface ISandboxDependencyStatus { + readonly bubblewrapInstalled: boolean; + readonly socatInstalled: boolean; +} + +export interface ISandboxHelperService { + readonly _serviceBrand: undefined; + checkSandboxDependencies(): Promise; +} diff --git a/src/vs/platform/sandbox/electron-browser/sandboxHelperService.ts b/src/vs/platform/sandbox/electron-browser/sandboxHelperService.ts new file mode 100644 index 0000000000000..804f37a9db07f --- /dev/null +++ b/src/vs/platform/sandbox/electron-browser/sandboxHelperService.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 { registerMainProcessRemoteService } from '../../ipc/electron-browser/services.js'; +import { ISandboxHelperService } from '../common/sandboxHelperService.js'; + +registerMainProcessRemoteService(ISandboxHelperService, 'sandboxHelper'); diff --git a/src/vs/platform/sandbox/electron-main/sandboxHelperService.ts b/src/vs/platform/sandbox/electron-main/sandboxHelperService.ts new file mode 100644 index 0000000000000..c02888905d10d --- /dev/null +++ b/src/vs/platform/sandbox/electron-main/sandboxHelperService.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ISandboxHelperService } from '../common/sandboxHelperService.js'; + +export const ISandboxHelperMainService = createDecorator('sandboxHelper'); + +export interface ISandboxHelperMainService extends ISandboxHelperService { + readonly _serviceBrand: undefined; +} diff --git a/src/vs/platform/sandbox/node/sandboxHelper.ts b/src/vs/platform/sandbox/node/sandboxHelper.ts new file mode 100644 index 0000000000000..43c9b1ba79a95 --- /dev/null +++ b/src/vs/platform/sandbox/node/sandboxHelper.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 { isLinux } from '../../../base/common/platform.js'; +import { findExecutable } from '../../../base/node/processes.js'; +import { ISandboxDependencyStatus, ISandboxHelperService } from '../common/sandboxHelperService.js'; + +type FindCommand = (command: string) => Promise; + +export class SandboxHelperService implements ISandboxHelperService { + declare readonly _serviceBrand: undefined; + + static async checkSandboxDependenciesWith(findCommand: FindCommand, linux: boolean = isLinux): Promise { + if (!linux) { + return undefined; + } + + const [bubblewrapPath, socatPath] = await Promise.all([ + findCommand('bwrap'), + findCommand('socat'), + ]); + + return { + bubblewrapInstalled: !!bubblewrapPath, + socatInstalled: !!socatPath, + }; + } + + checkSandboxDependencies(): Promise { + return SandboxHelperService.checkSandboxDependenciesWith(findExecutable); + } +} diff --git a/src/vs/platform/telemetry/common/errorTelemetry.ts b/src/vs/platform/telemetry/common/errorTelemetry.ts index 7eec5196f7dcd..d4396af26878f 100644 --- a/src/vs/platform/telemetry/common/errorTelemetry.ts +++ b/src/vs/platform/telemetry/common/errorTelemetry.ts @@ -44,6 +44,20 @@ export namespace ErrorEvent { } } +/** + * Extracts a callstack and message from an error object for telemetry. + * Handles the `Array.isArray(err.stack)` workaround from workerServer.ts + * and falls back to {@link safeStringify} when no message is available. + */ +export function packErrorForTelemetry(err: any): { callstack: string | undefined; msg: string } { + if (!err || typeof err !== 'object') { + return { callstack: undefined, msg: safeStringify(err) }; + } + const callstack: string | undefined = Array.isArray(err.stack) ? err.stack.join('\n') : err.stack; + const msg = err.message ? err.message : safeStringify(err); + return { callstack, msg }; +} + export default abstract class BaseErrorTelemetry { public static ERROR_FLUSH_TIMEOUT: number = 5 * 1000; @@ -98,8 +112,7 @@ export default abstract class BaseErrorTelemetry { } // work around behavior in workerServer.ts that breaks up Error.stack - const callstack = Array.isArray(err.stack) ? err.stack.join('\n') : err.stack; - const msg = err.message ? err.message : safeStringify(err); + const { callstack, msg } = packErrorForTelemetry(err); // errors without a stack are not useful telemetry if (!callstack) { diff --git a/src/vs/platform/telemetry/common/telemetry.ts b/src/vs/platform/telemetry/common/telemetry.ts index 11fd408cda03a..5cd0d8cbdc68d 100644 --- a/src/vs/platform/telemetry/common/telemetry.ts +++ b/src/vs/platform/telemetry/common/telemetry.ts @@ -51,6 +51,12 @@ export interface ITelemetryService { publicLogError2> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck): void; setExperimentProperty(name: string, value: string): void; + + /** + * Sets a common property that will be attached to all telemetry events. + * Common properties are added after PII cleaning and cannot be overridden by event data. + */ + setCommonProperty(name: string, value: string): void; } export function telemetryLevelEnabled(service: ITelemetryService, level: TelemetryLevel): boolean { diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index 445609fb7d33b..dfdd41ae6c14d 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -135,6 +135,10 @@ export class TelemetryService implements ITelemetryService { } } + setCommonProperty(name: string, value: string): void { + this._commonProperties[name] = value; + } + private _flushPendingEvents(): void { if (this._isExperimentPropertySet) { return; @@ -214,6 +218,11 @@ export class TelemetryService implements ITelemetryService { // add common properties data = mixin(data, this._commonProperties); + // tag error-level events so the backend can identify them generically + if (eventLevel === TelemetryLevel.ERROR) { + data = { ...data, 'isError': true }; + } + // Log to the appenders of sufficient level this._appenders.forEach(a => a.log(eventName, data ?? {})); } diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 7c5c89eae0886..dac6a8dfa8af8 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -40,6 +40,7 @@ export class NullTelemetryServiceShape implements ITelemetryService { publicLogError() { } publicLogError2() { } setExperimentProperty() { } + setCommonProperty() { } } export const NullTelemetryService = new NullTelemetryServiceShape(); @@ -309,13 +310,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 +331,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..ae80446734fcb 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})`, @@ -204,6 +212,22 @@ suite('TelemetryService', () => { service.dispose(); }); + test('setCommonProperty adds property to all subsequent events', function () { + const testAppender = new TestTelemetryAppender(); + const service = new TelemetryService({ + appenders: [testAppender], + }, new TestConfigurationService(), TestProductService); + + service.publicLog('eventBeforeSet'); + service.setCommonProperty('common.copilotTrackingId', 'test-tracking-id'); + service.publicLog('eventAfterSet'); + + assert.strictEqual(testAppender.events[0].data['common.copilotTrackingId'], undefined); + assert.strictEqual(testAppender.events[1].data['common.copilotTrackingId'], 'test-tracking-id'); + + service.dispose(); + }); + test('telemetry on by default', function () { const testAppender = new TestTelemetryAppender(); const service = new TelemetryService({ appenders: [testAppender] }, new TestConfigurationService(), TestProductService); @@ -468,10 +492,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 +526,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 +1057,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/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index dc7c596c346ca..0d0d6dc716e6b 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -719,6 +719,7 @@ export interface IShellLaunchConfigDto { reconnectionProperties?: IReconnectionProperties; type?: 'Task' | 'Local'; isFeatureTerminal?: boolean; + forceShellIntegration?: boolean; tabActions?: ITerminalTabAction[]; shellIntegrationEnvironmentReporting?: boolean; titleTemplate?: string; @@ -1060,6 +1061,10 @@ export const enum ShellIntegrationInjectionFailureReason { FailedToCreateTmpDir = 'failedToCreateTmpDir', } +export const enum ShellIntegrationTimeoutOverride { + DisableForTests = -2 +} + export enum TerminalExitReason { Unknown = 0, Shutdown = 1, 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/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index 4b7ab9e50c74d..86c55e5c7646f 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -184,6 +184,17 @@ suite('platform - terminalEnvironment', async () => { }); }); suite('bash', async () => { + suite('forceShellIntegration', async () => { + test('should inject when isFeatureTerminal is true but forceShellIntegration overrides it', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: [], isFeatureTerminal: true, forceShellIntegration: true }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'injection'); + }); + test('should not inject when isFeatureTerminal is true and forceShellIntegration is false', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: [], isFeatureTerminal: true, forceShellIntegration: false }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + }); + test('should not inject when isFeatureTerminal is true and forceShellIntegration is not set', async () => { + strictEqual((await getShellIntegrationInjection({ executable: 'bash', args: [], isFeatureTerminal: true }, enabledProcessOptions, defaultEnvironment, logService, productService, true)).type, 'failure'); + }); + }); suite('should override args', async () => { test('when undefined, [], empty string', async () => { const enabledExpectedResult = Object.freeze({ 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..c2146132485dd 100644 --- a/src/vs/platform/theme/electron-main/themeMainService.ts +++ b/src/vs/platform/theme/electron-main/themeMainService.ts @@ -23,4 +23,6 @@ export interface IThemeMainService { getWindowSplash(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined): IPartsSplash | undefined; getColorScheme(): IColorScheme; + + isAutoDetectColorScheme(): boolean; } diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 25e5fe37f7799..9e9258c04e89c 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,20 @@ 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; + } + 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..6061e15bd1f76 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -78,17 +78,12 @@ configurationRegistry.registerConfiguration({ description: localize('showReleaseNotes', "Show Release Notes after an update. The Release Notes are fetched from a Microsoft online service."), tags: ['usesOnlineServices'] }, - 'update.statusBar': { - type: 'string', - enum: ['hidden', 'actionable', 'detailed'], - default: 'detailed', + 'update.showPostInstallInfo': { + type: 'boolean', + default: true, scope: ConfigurationScope.APPLICATION, - description: localize('statusBar', "Controls the visibility of the update status bar entry."), - enumDescriptions: [ - localize('hidden', "The status bar entry is never shown."), - 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.") - ] + description: localize('showPostInstallInfo', "Show update information tooltip in the title bar after a new version is installed."), + included: false, } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 7f30494da4a37..92ae7b96f06ea 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -47,6 +47,7 @@ export const enum StateType { Updating = 'updating', Ready = 'ready', Overwriting = 'overwriting', + Restarting = 'restarting', } export const enum UpdateType { @@ -59,36 +60,38 @@ 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 }; +export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number; explicit: boolean }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; +export type Restarting = { type: StateType.Restarting; update: IUpdate }; -export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting; +export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting | Restarting; 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 }), + Updating: (update: IUpdate, explicit: boolean, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, explicit, currentProgress, maxProgress }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), + Restarting: (update: IUpdate): Restarting => ({ type: StateType.Restarting, update }), }; export interface IAutoUpdater extends Event.NodeEventEmitter { @@ -111,7 +114,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..8abc12decd9af 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,74 @@ 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; + }; + + 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.' }; + }; + + 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'), + }); + } + private getProductQuality(updateMode: string): string | undefined { return updateMode === 'none' ? undefined : this.productService.quality; } @@ -248,6 +341,7 @@ export abstract class AbstractUpdateService implements IUpdateService { } } + this.setState(State.Restarting(this.state.update)); this.logService.trace('update#quitAndInstall(): before lifecycle quit()'); this.lifecycleMainService.quit(true /* will restart */).then(vetod => { @@ -314,7 +408,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 +418,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 +436,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..7712a7c35dc43 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); @@ -159,8 +170,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun if (fastUpdatesEnabled && this.productService.target === 'user' && this.productService.commit) { const versionedResourcesFolder = this.productService.commit.substring(0, 10); const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); + const exeName = basename(exePath); + const siblingExeName = this.productService.win32SiblingExeBasename ? `${this.productService.win32SiblingExeBasename}.exe` : ''; await new Promise(resolve => { - const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder], { + const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName, siblingExeName], { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, timeout: 2 * 60 * 1000 @@ -188,8 +201,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 +211,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 +223,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 +233,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 +260,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']; @@ -335,7 +356,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const update = this.state.update; const explicit = this.state.explicit; - this.setState(State.Updating(update)); + this.setState(State.Updating(update, explicit)); const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); @@ -398,7 +419,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const maxProgress = parseInt(maxStr, 10); if (!isNaN(currentProgress) && !isNaN(maxProgress) && this.state.type === StateType.Updating) { if (this.state.currentProgress !== currentProgress || this.state.maxProgress !== maxProgress) { - this.setState(State.Updating(update, currentProgress, maxProgress)); + this.setState(State.Updating(update, explicit, currentProgress, maxProgress)); } } } @@ -471,7 +492,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override doQuitAndInstall(): void { - if (this.state.type !== StateType.Ready || !this.availableUpdate) { + if ((this.state.type !== StateType.Ready && this.state.type !== StateType.Restarting) || !this.availableUpdate) { return; } 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..0bfb8ccfa2ca7 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'; @@ -41,7 +41,7 @@ export interface IWindowsMainService { openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; - openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise; + openAgentsWindow(openConfig: IOpenConfiguration): Promise; sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; @@ -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..bf6622eb54d6f 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -17,7 +17,7 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { Schemas } from '../../../base/common/network.js'; import { basename, join, normalize, posix } from '../../../base/common/path.js'; import { getMarks, mark } from '../../../base/common/performance.js'; -import { IProcessEnvironment, isMacintosh, isWindows, OS } from '../../../base/common/platform.js'; +import { INodeProcess, IProcessEnvironment, isMacintosh, isWindows, OS } from '../../../base/common/platform.js'; import { cwd } from '../../../base/common/process.js'; import { extUriBiasedIgnorePathCase, isEqual, isEqualAuthority, normalizePath, originalFSPath, removeTrailingPathSeparator } from '../../../base/common/resources.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; @@ -39,7 +39,7 @@ import { getRemoteAuthority } from '../../remote/common/remoteHosts.js'; import { IStateService } from '../../state/node/state.js'; import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; import { CodeWindow } from './windowImpl.js'; -import { IBaseOpenConfiguration, IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; +import { IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; import { findWindowOnExtensionDevelopmentPath, findWindowOnFile, findWindowOnWorkspaceOrFolder } from './windowsFinder.js'; import { IWindowState, WindowsStateHandler } from './windowsStateHandler.js'; import { IRecent } from '../../workspaces/common/workspaces.js'; @@ -292,12 +292,17 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } - async openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise { - this.logService.trace('windowsManager#openSessionsWindow'); + async openAgentsWindow(openConfig: IOpenConfiguration): Promise { + this.logService.trace('windowsManager#openAgentsWindow'); + // Open in a new browser window with the agent sessions workspace + return this.open(await this.ensureAgentsWindow(openConfig)); + } + + private async ensureAgentsWindow(openConfig: IOpenConfiguration): Promise { const agentSessionsWorkspaceUri = this.environmentMainService.agentSessionsWorkspace; if (!agentSessionsWorkspaceUri) { - throw new Error('Sessions workspace is not configured'); + throw new Error('Agents workspace is not configured'); } // Ensure the workspace file exists @@ -307,19 +312,26 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic await this.fileService.writeFile(agentSessionsWorkspaceUri, VSBuffer.fromString(emptyWorkspaceContent)); } - // Open in a new browser window with the agent sessions workspace - return this.open({ - ...openConfig, + return { urisToOpen: [{ workspaceUri: agentSessionsWorkspaceUri }], - cli: this.environmentMainService.args, - forceNewWindow: true, + userEnv: openConfig.userEnv, + cli: openConfig.cli, noRecentEntry: true, - }); + context: openConfig.context, + contextWindowId: openConfig.contextWindowId, + initialStartup: openConfig.initialStartup, + }; } async open(openConfig: IOpenConfiguration): Promise { this.logService.trace('windowsManager#open'); + // Take care of agents app specially + const isAgentsApp = (process as INodeProcess).isEmbeddedApp; + if (isAgentsApp) { + openConfig = await this.ensureAgentsWindow(openConfig); + } + // Make sure addMode/removeMode is only enabled if we have an active window if ((openConfig.addMode || openConfig.removeMode) && (openConfig.initialStartup || !this.getLastActiveWindow())) { openConfig.addMode = false; @@ -388,7 +400,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic } // These are windows to restore because of hot-exit or from previous session (only performed once on startup!) - if (openConfig.initialStartup) { + if (openConfig.initialStartup && !isAgentsApp /* skipped for agents app */) { // Untitled workspaces are always restored untitledWorkspacesToRestore.push(...this.workspacesManagementMainService.getUntitledWorkspaces()); 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/platform/workspaces/electron-main/workspacesHistoryMainService.ts b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts index 104f114ab8977..29bbbcce877ab 100644 --- a/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts +++ b/src/vs/platform/workspaces/electron-main/workspacesHistoryMainService.ts @@ -10,7 +10,7 @@ import { Emitter, Event as CommonEvent } from '../../../base/common/event.js'; import { normalizeDriveLetter, splitRecentLabel } from '../../../base/common/labels.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Schemas } from '../../../base/common/network.js'; -import { isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { isMacintosh, INodeProcess, isWindows } from '../../../base/common/platform.js'; import { basename, extUriBiasedIgnorePathCase, originalFSPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { Promises } from '../../../base/node/pfs.js'; @@ -107,7 +107,8 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa // Add to recent documents (Windows only, macOS later) // Skip in portable mode to avoid leaving traces on the machine - if (isWindows && recent.fileUri.scheme === Schemas.file && !this.environmentMainService.isPortable) { + // Skip in the sessions app to avoid polluting the jump list + if (isWindows && recent.fileUri.scheme === Schemas.file && !this.environmentMainService.isPortable && !(process as INodeProcess).isEmbeddedApp) { app.addRecentDocument(recent.fileUri.fsPath); } } @@ -325,6 +326,11 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa return; } + // Skip in the sessions app to avoid polluting the jump list + if ((process as INodeProcess).isEmbeddedApp) { + return; + } + await this.updateWindowsJumpList(); this._register(this.onDidChangeRecentlyOpened(() => this.updateWindowsJumpList())); } @@ -455,6 +461,11 @@ export class WorkspacesHistoryMainService extends Disposable implements IWorkspa return; } + // Skip in the sessions app to avoid polluting the dock + if ((process as INodeProcess).isEmbeddedApp) { + return; + } + // We clear all documents first to ensure an up-to-date view on the set. Since entries // can get deleted on disk, this ensures that the list is always valid app.clearRecentDocuments(); 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..05cdf58e91ded 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -204,6 +204,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel< reconnectionProperties: args.shellLaunchConfig.reconnectionProperties, type: args.shellLaunchConfig.type, isFeatureTerminal: args.shellLaunchConfig.isFeatureTerminal, + forceShellIntegration: args.shellLaunchConfig.forceShellIntegration, tabActions: args.shellLaunchConfig.tabActions, shellIntegrationEnvironmentReporting: args.shellLaunchConfig.shellIntegrationEnvironmentReporting, }; @@ -226,7 +227,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..e8fc3214c6f2e 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'; @@ -96,6 +99,8 @@ import { McpManagementChannel } from '../../platform/mcp/common/mcpManagementIpc import { AllowedMcpServersService } from '../../platform/mcp/common/allowedMcpServersService.js'; import { IMcpGalleryManifestService } from '../../platform/mcp/common/mcpGalleryManifest.js'; import { McpGalleryManifestIPCService } from '../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; +import { SANDBOX_HELPER_CHANNEL_NAME, SandboxHelperChannel } from '../../platform/sandbox/common/sandboxHelperIpc.js'; +import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelper.js'; const eventPrefix = 'monacoworkbench'; @@ -228,6 +233,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)); @@ -245,6 +267,8 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const telemetryChannel = new ServerTelemetryChannel(accessor.get(IServerTelemetryService), oneDsAppender); socketServer.registerChannel('telemetry', telemetryChannel); + socketServer.registerChannel(SANDBOX_HELPER_CHANNEL_NAME, new SandboxHelperChannel(new SandboxHelperService())); + socketServer.registerChannel(REMOTE_TERMINAL_CHANNEL_NAME, new RemoteTerminalChannel(environmentService, logService, ptyHostService, productService, extensionManagementService, configurationService)); const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); 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..0edfdbbbf78cc 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,94 +1,239 @@ # 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` (agent 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 | Agent 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 skills**: Discovers bundled `SKILL.md` files from `vs/sessions/skills/{name}/` and surfaces them with `PromptsStorage.builtin` storage type +- **User override**: Built-in skills are omitted when a user or workspace skill 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 Skills + +All built-in customizations bundled with the Sessions app are skills, living in `src/vs/sessions/skills/{name}/SKILL.md`. They are: + +- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/skills')` +- 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 skill shares the same name (override behavior) +- Skills with UI integrations (e.g. `act-on-feedback`, `generate-run-commands`) display a "UI Integration" badge in the management editor + +### UI Integration Badges + +Skills that are directly invoked by UI elements (toolbar buttons, menu items) are annotated with a "UI Integration" badge in the management editor. The mapping is provided by `IAICustomizationWorkspaceService.getSkillUIIntegrations()`, which the Sessions implementation populates with the relevant skill names and tooltip descriptions. The badge appears on both the built-in skill and any user/workspace override, ensuring users understand that overriding the skill affects a UI surface. + +### 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. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. 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..981cbc934949b 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -82,11 +82,12 @@ The Agent Sessions titlebar includes a command center with a custom title bar wi The widget: - Extends `BaseActionViewItem` and renders a clickable label showing the active session title -- Shows kind icon (provider type icon), session title, repository folder name, and changes summary (+insertions -deletions) +- Shows kind icon (provider type icon), session title, repository folder name, and the active git branch/worktree name in parentheses when available, plus the changes summary (+insertions -deletions) - On click, opens the `AgentSessionsPicker` quick pick to switch between sessions - Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found - Re-renders automatically when the active session changes via `autorun` on `IActiveSessionService.activeSession`, and when session data changes via `IAgentSessionsService.model.onDidChangeSessions` - Is registered via `SessionsTitleBarContribution` (an `IWorkbenchContribution` in `contrib/sessions/browser/sessionsTitleBarWidget.ts`) that calls `IActionViewItemService.register()` to intercept the submenu rendering +- Uses `padding-left: 0` while the sidebar is visible, and restores `padding-left: 16px` when the sidebar is hidden via the `nosidebar` workbench class ### 3.3 Left Toolbar @@ -172,6 +173,8 @@ This structure places the sidebar at the root level spanning the full window hei | Panel | 300px height | | Titlebar | Determined by `minimumHeight` (~30px) | +The sessions sidebar can be resized down to a minimum width of 170px. + ### 4.3 Editor Modal The main editor part is created but hidden (`display:none`). It exists for future use but is not currently visible. All editors are forced to open in the `ModalEditorPart` overlay via the standard `createModalEditorPart()` mechanism. @@ -198,7 +201,7 @@ When the setting is `'all'`: The setting `workbench.editor.useModal` is an enum with three values: - `'off'`: Editors never open in a modal overlay - `'some'`: Certain editors (e.g. Settings, Keyboard Shortcuts) may open in a modal overlay when requested via `MODAL_GROUP` -- `'all'`: All editors open in a modal overlay (used by sessions window) +- `'all'`: All editors open in a modal overlay (used by agent sessions window) --- @@ -374,7 +377,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 +443,9 @@ 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). + +The sessions appear animation applies only to the sidebar body (`.part.sidebar > .content`). The sidebar container, title area, and footer do not participate in the reveal animation, so the header region stays visually fixed while the body slides/fades in. Normal hover and pressed feedback for header/footer controls is preserved. --- @@ -471,8 +476,9 @@ The Changes view is registered in `contrib/changesView/browser/changesView.contr The Sessions view is registered in `contrib/sessions/browser/sessions.contribution.ts`: - **Container**: Sessions container in `ViewContainerLocation.Sidebar` (default) -- **View**: `SessionsViewId` with `AgenticSessionsViewPane` +- **View**: `SessionsViewId` with `SessionsView` (`contrib/sessions/browser/views/sessionsView.ts`) - **Window visibility**: `WindowVisibility.Sessions` +- **Primary action**: The sidebar content starts with a left-aligned secondary "New Session" button rendered as `$(plus) Session`, with an inline shortcut hint that reflects the active `workbench.action.sessions.newChat` keybinding when one is available --- @@ -640,6 +646,14 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-03 | Updated `SessionsTitleBarWidget` to format active session titles as `{Title} · {repo name} ({git branch/worktree name})` when repository detail metadata is available, falling back to the worktree folder name when needed. | +| 2026-04-03 | Reduced the sessions left sidebar minimum resizable width from 270px to 170px so it can shrink significantly more while keeping the default 300px width unchanged | +| 2026-03-30 | Adjusted `.agent-sessions-titlebar-container` padding so it sits flush when the sidebar is visible and restores 16px left padding when the sidebar is hidden | +| 2026-03-26 | Updated the sessions sidebar appear animation so only the body content (`.part.sidebar > .content`) slides/fades in during reveal while the sidebar title/header and footer remain fixed | +| 2026-03-24 | Polished the sessions task configuration quick input modal to use stronger modal-style header chrome, increased horizontal padding in the quick input/form content, and added an explicit close action in the modal header | +| 2026-03-25 | Updated Sessions view documentation to reflect the refactored `SessionsView` implementation in `contrib/sessions/browser/views/sessionsView.ts` and documented the left-aligned "+ Session" sidebar action with its inline keybinding hint | +| 2026-03-24 | Updated the sessions new-chat empty state: removed the watermark, vertically centered the empty-state controls block, restyled the workspace picker as an inline `New session in {dropdown}` title row aligned to the chat input, and tuned empty-state dropdown icon/chevron and local-mode spacing for the final visual polish. | +| 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..5903e497d3a3d 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 agent 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..de8969d629c17 --- /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 Agent 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, Changes | Terminal) │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────▼────────────┐ + │ 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), supports markdown | +| `lastTurnEnd` | `IObservable` | When the last agent turn ended | +| `pullRequest` | `IObservable` | Associated pull request | + +#### 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..a15c7e5e49619 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -13,31 +13,26 @@ 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 { IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.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.")); +const sidebarToggleClosedIcon = registerIcon('agent-sidebar-toggle-closed', Codicon.layoutSidebarLeftOff, localize('agentSidebarToggleClosedIcon', "Icon for the sessions sidebar when closed.")); +const sidebarToggleOpenIcon = registerIcon('agent-sidebar-toggle-open', Codicon.layoutSidebarLeft, localize('agentSidebarToggleOpenIcon', "Icon for the sessions sidebar when open.")); class ToggleSidebarVisibilityAction extends Action2 { static readonly ID = 'workbench.action.agentToggleSidebarVisibility'; - static readonly LABEL = localize('compositePart.hideSideBarLabel', "Hide Primary Side Bar"); constructor() { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: panelLeftOffIcon, + icon: sidebarToggleClosedIcon, 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: sidebarToggleOpenIcon, }, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), @@ -50,7 +45,7 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeft, + id: Menus.TitleBarLeftLayout, group: 'navigation', order: 0, when: IsAuxiliaryWindowContext.toNegated() @@ -82,31 +77,18 @@ class ToggleSidebarVisibilityAction extends Action2 { class ToggleSecondarySidebarVisibilityAction extends Action2 { static readonly ID = 'workbench.action.agentToggleSecondarySidebarVisibility'; - static readonly LABEL = localize('compositePart.hideSecondarySideBarLabel', "Hide Secondary Side Bar"); constructor() { 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'), }, category: Categories.View, f1: true, menu: [ - { - id: Menus.TitleBarRight, - group: 'navigation', - order: 10, - when: IsAuxiliaryWindowContext.toNegated() - }, { id: Menus.TitleBarContext, order: 1, @@ -163,7 +145,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..2d59d94d388e9 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -15,42 +15,252 @@ * * 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 10px 0px 10px; + 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 10px 0px 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 10px 10px 10px; 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.panel { + margin-left: 0; } -/* Grid background matches the chat bar / sidebar background */ +/* 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: 12px !important; + padding-right: 12px !important; + box-sizing: border-box; +} + +.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { + max-width: 950px; + margin: 0 auto; + padding-left: 12px !important; + padding-right: 12px !important; + 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; +} + +/* Same fade overlay for the chat renderer inside an editor (e.g. floating aux window). */ +.agent-sessions-workbench .chat-editor-relative .interactive-list .monaco-scrollable-element > .shadow.top { + height: 16px; + background: linear-gradient(to bottom, var(--vscode-editor-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); } - .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 10px 10px 10px !important; + box-sizing: border-box; +} + +/* Hide shared chat-session option-group pickers in the sessions app active chat UI. + * The sessions workbench provides its own new-session configuration controls and + * should not surface the shared workbench chat session pickers here. */ +.agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { + display: none; +} + +/* ---- 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). + * + * Auxiliary bar, panel, and chat bar animate their container via opacity, + * margin, border-color, and background. The sidebar reveal animation applies + * only to `.part.sidebar > .content`, using opacity + transform so the + * sidebar container, header, and footer stay visually fixed. + * + * 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 and transform shifts are purely visual + * within the grid-allocated space. + */ + +.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; +} + +/* Auxiliary bar also transitions horizontal margin */ +.agent-sessions-workbench .part.auxiliarybar { + transition: + opacity 250ms ease-out, + margin 250ms ease-out, + border-color 250ms ease-out, + background 250ms ease-out; +} + +.agent-sessions-workbench .part.sidebar > .content { + transition: + opacity 250ms ease-out, + transform 250ms ease-out; +} + +@starting-style { + /* Shared starting values */ + .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)); + } + + .agent-sessions-workbench .part.sidebar > .content { + opacity: 0; + transform: translateX(-6px); + } + + /* Panel (bottom): slides down from 6px above → margin: 0 10px 10px 10px */ + .agent-sessions-workbench .part.panel { + margin: 6px 16px 16px 16px; + } + + /* Auxiliary bar (right): slides in from 6px right → margin: 0 10px 0px 0 */ + .agent-sessions-workbench .part.auxiliarybar { + margin: 0 16px 0px 6px; + } + + /* Chat bar (center-bottom): slides up from 6px below → margin: 0 10px 0px 10px */ + .agent-sessions-workbench .part.chatbar { + margin: 6px 16px 0px 16px; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel, + .agent-sessions-workbench .part.chatbar { + transition: none; + } + + .agent-sessions-workbench .part.sidebar > .content { + transition: none; + } +} + +/* ---- Widget Customizations ---- */ + +/* Action Widget */ +.agent-sessions-workbench .action-widget .monaco-list .monaco-list-row { + padding-right: 0; +} + +/* 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..7f140d2fd7b9c 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 = 10; + static readonly MARGIN_BOTTOM = 0; + static readonly MARGIN_RIGHT = 10; // Action ID for run script - defined here to avoid layering issues private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; @@ -59,8 +61,8 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { private readonly _runScriptMenu = this._register(new MutableDisposable()); private readonly _runScriptMenuListener = this._register(new MutableDisposable()); - // Use the side bar dimensions - override readonly minimumWidth: number = 170; + // Sessions-specific auxiliary bar dimensions (intentionally not tied to the sessions SidebarPart values) + override readonly minimumWidth: number = 270; override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; @@ -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..daf529b8d3088 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'; @@ -26,37 +29,39 @@ import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { Menus } from '../menus.js'; import { ActiveChatBarContext, ChatBarFocusContext } from '../../common/contextkeys.js'; +import { SessionCompositeBar } from './sessionCompositeBar.js'; +import { prepend } from '../../../base/browser/dom.js'; -export class ChatBarPart extends AbstractPaneCompositePart { +export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not be a AbstractPaneCompositePart but instead a custom Part with a CompositeBar static readonly activeViewSettingsKey = 'workbench.chatbar.activepanelid'; static readonly pinnedViewsKey = 'workbench.chatbar.pinnedPanels'; 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; - } + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_TOP = 10; + static readonly MARGIN_LEFT = 10; + static readonly MARGIN_RIGHT = 10; + static readonly MARGIN_BOTTOM = 0; - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); + /** Border width on the card (1px each side) */ + static readonly BORDER_WIDTH = 1; - if (!activeComposite) { - return undefined; - } + /** Height of the session composite bar when visible */ + private static readonly SESSION_BAR_HEIGHT = 35; - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } + private _sessionCompositeBar: SessionCompositeBar | undefined; + + private _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; - return Math.max(width, 300); + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; } readonly priority = LayoutPriority.High; @@ -108,14 +113,56 @@ export class ChatBarPart extends AbstractPaneCompositePart { ); } + override create(parent: HTMLElement): void { + super.create(parent); + + // Create the session composite bar and prepend it before the content area + this._sessionCompositeBar = this._register(this.instantiationService.createInstance(SessionCompositeBar)); + prepend(parent, this._sessionCompositeBar.element); + + // Relayout when session bar visibility changes + this._register(this._sessionCompositeBar.onDidChangeVisibility(() => { + if (this._lastLayout) { + this.layout(this._lastLayout.width, this._lastLayout.height, this._lastLayout.top, this._lastLayout.left); + } + })); + } + override updateStyles(): void { 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; + } + + this._lastLayout = { width, height, top, left }; + + // Account for the session composite bar height when visible + const sessionBarHeight = this._sessionCompositeBar?.visible ? ChatBarPart.SESSION_BAR_HEIGHT : 0; + + // 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 - sessionBarHeight, + 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 +180,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..abb40cea4b4d6 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * 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 for non-focused items, but keep it for keyboard focus */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible) .active-item-indicator:before { + display: none !important; +} +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus-visible .active-item-indicator:before { + display: block !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..b66897c08405f 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -7,3 +7,45 @@ .monaco-workbench .part.panel.bottom .composite.title { border-top-width: 0; } + +/* ===== Modern action label styling for sessions panel ===== */ + +/* Hide the underline indicator for non-focused, non-checked items; keep it for focus-visible and checked */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(:focus-visible):not(.checked) .active-item-indicator:before { + display: none !important; +} +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus-visible .active-item-indicator:before { + display: block !important; +} +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before { + display: block !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/sessionCompositeBar.css b/src/vs/sessions/browser/parts/media/sessionCompositeBar.css new file mode 100644 index 0000000000000..2e923fc4b69d3 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sessionCompositeBar.css @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ===== Session composite bar in the chat bar ===== */ + +.session-composite-bar { + display: flex; + align-items: center; + background-color: var(--session-bar-background); + padding: 0 4px; + height: 35px; + flex-shrink: 0; + overflow: hidden; +} + +.session-composite-bar-tabs { + display: flex; + align-items: center; + height: 100%; + gap: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.session-composite-bar-tabs::-webkit-scrollbar { + display: none; +} + +/* Base tab: capitalize text + pill padding — mirrors auxiliarybar action-label */ +.session-composite-bar-tab { + display: flex; + align-items: center; + position: relative; + padding: 0 8px; + cursor: pointer; + white-space: nowrap; + color: var(--session-tab-inactive-foreground); + text-transform: capitalize; + font-weight: 500; + font-size: 12px; + line-height: 22px; + height: 26px; + border-radius: 4px; + user-select: none; +} + +.session-composite-bar-tab:hover { + color: var(--session-tab-active-foreground); +} + +/* Active state: background container instead of underline — mirrors auxiliarybar checked */ +.session-composite-bar-tab.active { + color: var(--session-tab-active-foreground); + background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)); +} + +/* Hide the underline indicator — mirrors auxiliarybar active-item-indicator hiding */ +.session-composite-bar-tab-indicator { + display: none; +} + +.session-composite-bar-tab:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css index 0162bcb26d036..fd71ae41a81d7 100644 --- a/src/vs/sessions/browser/parts/media/sidebarPart.css +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -28,16 +28,43 @@ -webkit-app-region: no-drag; } +/* Toggled state for the sidebar toggle button in the sidebar title area */ +.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + +/* Preserve toggled background when the toggle button is hovered or focused */ +.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked:hover, +.agent-sessions-workbench .part.sidebar > .composite.title > .global-actions-left .action-label.checked:focus { + background: var(--vscode-toolbar-activeBackground); +} /* 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 0 0; + border-top: 1px solid var(--vscode-panel-border, transparent); + margin: 0 10px 2px 10px; 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: 10px; + 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, diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 22273655103c7..29aeb4c1ddaa4 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 (only when session actions toolbar also has actions) */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-right-layout-container:not(.has-no-actions)::before { + content: ''; + width: 1px; + height: 16px; + margin: 0 8px; + 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 { - color: inherit; +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item:not(.disabled) .codicon { + color: var(--vscode-icon-foreground); } -.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..225d9843cb4dc 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 = 10; + static readonly MARGIN_LEFT = 10; + static readonly MARGIN_RIGHT = 10; 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/sessionCompositeBar.ts b/src/vs/sessions/browser/parts/sessionCompositeBar.ts new file mode 100644 index 0000000000000..81acf48a8dfda --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionCompositeBar.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionCompositeBar.css'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { $, addDisposableListener, EventType, getWindow, reset } from '../../../base/browser/dom.js'; +import { autorun } from '../../../base/common/observable.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND } from '../../../workbench/common/theme.js'; +import { Action } from '../../../base/common/actions.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { localize } from '../../../nls.js'; +import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; +// TODO: we should move the sessions management service to a more core location so that we don't have to depend on the entire sessions contrib in this common/browser component +// eslint-disable-next-line local/code-import-patterns +import { ISessionsManagementService } from '../../contrib/sessions/browser/sessionsManagementService.js'; +// eslint-disable-next-line local/code-import-patterns +import { IChat } from '../../contrib/sessions/common/sessionData.js'; + +interface ISessionTab { + readonly chat: IChat; + readonly element: HTMLElement; +} + +/** + * A composite bar that displays chats within the active agent session as tabs. + * Selecting a tab loads that chat in the chat view pane instead of switching view containers. + * + * The bar auto-hides when there is only one chat in the active session and shows when there are multiple. + */ +export class SessionCompositeBar extends Disposable { + + private readonly _container: HTMLElement; + private readonly _tabsContainer: HTMLElement; + private readonly _tabs: ISessionTab[] = []; + private readonly _tabDisposables = this._register(new DisposableStore()); + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + + private _visible = false; + + get element(): HTMLElement { + return this._container; + } + + get visible(): boolean { + return this._visible; + } + + constructor( + @IThemeService private readonly _themeService: IThemeService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + ) { + super(); + + this._container = $('.session-composite-bar'); + this._tabsContainer = $('.session-composite-bar-tabs'); + this._container.appendChild(this._tabsContainer); + + // Track active session changes + this._register(autorun(reader => { + const activeSession = this._sessionsManagementService.activeSession.read(reader); + if (!activeSession) { + this._rebuildTabs([], '', undefined); + return; + } + + const chats = activeSession.chats.read(reader); + const activeChatUri = activeSession.activeChat.read(reader)?.resource.toString() ?? ''; + const mainChatUri = activeSession.mainChat.resource.toString(); + this._rebuildTabs(chats, activeChatUri, mainChatUri); + })); + + + this._updateStyles(); + this._register(this._themeService.onDidColorThemeChange(() => this._updateStyles())); + } + + private _rebuildTabs(chats: readonly IChat[], activeChatId: string, mainChatId?: string): void { + this._tabDisposables.clear(); + this._tabs.length = 0; + reset(this._tabsContainer); + + for (const chat of chats) { + this._createTab(chat, chat.resource.toString() === mainChatId); + } + + this._updateActiveTab(activeChatId); + this._updateVisibility(); + } + + private _createTab(chat: IChat, isMainChat: boolean): void { + const tab = $('.session-composite-bar-tab'); + tab.tabIndex = 0; + tab.setAttribute('role', 'tab'); + + const labelEl = $('.session-composite-bar-tab-label'); + this._tabDisposables.add(autorun(reader => { + const title = chat.title.read(reader); + labelEl.textContent = title; + })); + tab.appendChild(labelEl); + + const indicator = $('.session-composite-bar-tab-indicator'); + tab.appendChild(indicator); + + this._tabsContainer.appendChild(tab); + + this._tabDisposables.add(addDisposableListener(tab, EventType.CLICK, () => { + this._onTabClicked(chat); + })); + + this._tabDisposables.add(addDisposableListener(tab, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this._onTabClicked(chat); + } + })); + + const renameAction = this._tabDisposables.add(new Action('sessionCompositeBar.renameChat', localize('renameChat', "Rename"), undefined, true, async () => { + const newTitle = await this._quickInputService.input({ + value: chat.title.get(), + prompt: localize('renameChat.prompt', "Rename Chat"), + }); + if (newTitle) { + const session = this._sessionsManagementService.activeSession.get(); + if (session) { + await this._sessionsManagementService.renameChat(session, chat.resource, newTitle); + } + } + })); + + const deleteAction = this._tabDisposables.add(new Action('sessionCompositeBar.deleteChat', localize('deleteChat', "Delete"), undefined, !isMainChat, async () => { + const session = this._sessionsManagementService.activeSession.get(); + if (session) { + await this._sessionsManagementService.deleteChat(session, chat.resource); + } + })); + + this._tabDisposables.add(addDisposableListener(tab, EventType.CONTEXT_MENU, (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const event = new StandardMouseEvent(getWindow(tab), e); + this._contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => [ + renameAction, + deleteAction, + ] + }); + })); + + this._tabs.push({ chat: chat, element: tab }); + } + + private _onTabClicked(chat: IChat): void { + const session = this._sessionsManagementService.activeSession.get(); + if (session) { + this._sessionsManagementService.openChat(session, chat.resource); + } + } + + private _updateActiveTab(activeChatId: string): void { + for (const tab of this._tabs) { + const isActive = tab.chat.resource.toString() === activeChatId; + tab.element.classList.toggle('active', isActive); + tab.element.setAttribute('aria-selected', String(isActive)); + } + } + + private _updateVisibility(): void { + // Show when there are multiple sessions, hide when there is only one (or none) + const wasVisible = this._visible; + this._visible = this._tabs.length > 1; + this._container.style.display = this._visible ? '' : 'none'; + if (wasVisible !== this._visible) { + this._onDidChangeVisibility.fire(this._visible); + } + } + + private _updateStyles(): void { + const theme = this._themeService.getColorTheme(); + + const bg = theme.getColor(SIDE_BAR_BACKGROUND); + const activeFg = theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND); + const inactiveFg = theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND); + const activeBorder = theme.getColor(PANEL_ACTIVE_TITLE_BORDER); + + this._container.style.setProperty('--session-bar-background', bg?.toString() ?? ''); + this._container.style.setProperty('--session-tab-active-foreground', activeFg?.toString() ?? ''); + this._container.style.setProperty('--session-tab-inactive-foreground', inactiveFg?.toString() ?? ''); + this._container.style.setProperty('--session-tab-active-border', activeBorder?.toString() ?? ''); + } +} diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index 5f8cce31cf807..0bd85fb27d657 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'; @@ -35,9 +34,11 @@ import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { Menus } from '../menus.js'; import { $, append, 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 { isMacintosh, isNative } from '../../../base/common/platform.js'; /** * Sidebar part specifically for agent sessions workbench. @@ -57,6 +58,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 = 2; + private static readonly FOOTER_BORDER_TOP = 1; private footerContainer: HTMLElement | undefined; private sideBarTitleArea: HTMLElement | undefined; @@ -103,10 +106,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 +121,7 @@ export class SidebarPart extends AbstractPaneCompositePart { ViewContainerLocation.Sidebar, Extensions.Viewlets, Menus.SidebarTitle, - Menus.TitleBarLeft, + Menus.TitleBarLeftLayout, notificationService, storageService, contextMenuService, @@ -151,7 +155,7 @@ export class SidebarPart extends AbstractPaneCompositePart { // 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%'; @@ -200,7 +204,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 +223,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 +297,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..ccce4bbc1f43b 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,12 @@ 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 chatBarResizeObserver: ResizeObserver | undefined; + private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; @@ -132,7 +138,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 +191,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 +209,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 +270,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,37 +282,38 @@ 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); + this.installChatBarResizeObserver(); } - private updateLayout(): void { - if (!hasCustomTitlebar(this.configurationService, this.titleBarStyle)) { + private installChatBarResizeObserver(): void { + if (this.chatBarResizeObserver) { return; } - const zoomFactor = getZoomFactor(getWindow(this.element)); - this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); - this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); + const chatBarContainer = this.layoutService.getContainer(getWindow(this.element), Parts.CHATBAR_PART); + if (!chatBarContainer) { + return; + } - this.updateCenterOffset(); + this.chatBarResizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + this.centerContent.style.maxWidth = `${entry.contentRect.width}px`; + } + }); + this.chatBarResizeObserver.observe(chatBarContainer); + this._register({ dispose: () => this.chatBarResizeObserver?.disconnect() }); } - private updateCenterOffset(): void { - if (!this.centerContent || !this.lastLayoutDimension) { + private updateLayout(): void { + if (!hasCustomTitlebar(this.configurationService, this.titleBarStyle)) { 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` : ''; + const zoomFactor = getZoomFactor(getWindow(this.element)); + this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); + this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); } 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..291f1ac978927 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, // agents 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/categories.ts b/src/vs/sessions/common/categories.ts index 6b5ac08a43387..4645c05dc615a 100644 --- a/src/vs/sessions/common/categories.ts +++ b/src/vs/sessions/common/categories.ts @@ -6,5 +6,5 @@ import { localize2 } from '../../nls.js'; export const SessionsCategories = Object.freeze({ - Sessions: localize2('sessions', "Sessions"), + Sessions: localize2('agents', "Agents"), }); diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index d95d7411ac4cf..dd02a136de7e9 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -6,9 +6,14 @@ import { localize } from '../../nls.js'; import { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; -//#region < --- Welcome --- > +//#region < --- Active Session --- > -export const SessionsWelcomeCompleteContext = new RawContextKey('sessionsWelcomeComplete', false, localize('sessionsWelcomeComplete', "Whether the sessions welcome setup is complete")); +export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); +export const ActiveSessionProviderIdContext = new RawContextKey('activeSessionProviderId', '', localize('activeSessionProviderId', "The provider ID of the active session")); +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")); +export const ActiveSessionHasGitRepositoryContext = new RawContextKey('activeSessionHasGitRepository', false, localize('activeSessionHasGitRepository', "Whether the active session has an associated git repository")); +export const ChatSessionProviderIdContext = new RawContextKey('chatSessionProviderId', '', localize('chatSessionProviderId', "The provider ID of a session in context menu overlays")); //#endregion @@ -19,3 +24,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/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts new file mode 100644 index 0000000000000..0b2974d9b52c5 --- /dev/null +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; + +// --- Titlebar button interactions --- + +export type SessionsInteractionButton = + | 'newSession' + | 'runPrimaryTask' + | 'addTask' + | 'generateNewTask' + | 'openTerminal' + | 'openInVSCode'; + +type SessionsInteractionEvent = { + button: string; +}; + +type SessionsInteractionClassification = { + owner: 'osortega'; + comment: 'Tracks user interactions with buttons in the Agents window'; + button: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the button that was clicked' }; +}; + +/** + * Log a titlebar button interaction in the Agents window. + */ +export function logSessionsInteraction(telemetryService: ITelemetryService, button: SessionsInteractionButton): void { + telemetryService.publicLog2('vscodeAgents.interaction', { button }); +} + +// --- Changes panel interactions --- + +type ChangesViewTogglePanelEvent = { + visible: boolean; +}; + +type ChangesViewTogglePanelClassification = { + owner: 'osortega'; + comment: 'Tracks when the user toggles the Changes panel open or closed.'; + visible: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the Changes panel is now visible.' }; +}; + +export function logChangesViewToggle(telemetryService: ITelemetryService, visible: boolean): void { + telemetryService.publicLog2('vscodeAgents.changesView/togglePanel', { visible }); +} + +type ChangesViewVersionModeChangeEvent = { + mode: string; +}; + +type ChangesViewVersionModeChangeClassification = { + owner: 'osortega'; + comment: 'Tracks when the user switches the version mode in the Changes panel (Branch Changes, All Changes, Last Turn).'; + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version mode selected by the user.' }; +}; + +export function logChangesViewVersionModeChange(telemetryService: ITelemetryService, mode: string): void { + telemetryService.publicLog2('vscodeAgents.changesView/versionModeChange', { mode }); +} + +type ChangesViewFileSelectEvent = { + changeType: string; +}; + +type ChangesViewFileSelectClassification = { + owner: 'osortega'; + comment: 'Tracks when the user selects a changed file in the Changes panel.'; + changeType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The type of change (added, modified, deleted).' }; +}; + +export function logChangesViewFileSelect(telemetryService: ITelemetryService, changeType: string): void { + telemetryService.publicLog2('vscodeAgents.changesView/fileSelect', { changeType }); +} + +type ChangesViewViewModeChangeEvent = { + mode: string; +}; + +type ChangesViewViewModeChangeClassification = { + owner: 'osortega'; + comment: 'Tracks when the user switches between list and tree view modes in the Changes panel.'; + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The view mode selected by the user (list or tree).' }; +}; + +export function logChangesViewViewModeChange(telemetryService: ITelemetryService, mode: string): void { + telemetryService.publicLog2('vscodeAgents.changesView/viewModeChange', { mode }); +} + +type ChangesViewReviewCommentAddedEvent = { + hasExistingFeedback: boolean; + hasSuggestion: boolean; + isFromPRReview: boolean; +}; + +type ChangesViewReviewCommentAddedClassification = { + owner: 'osortega'; + comment: 'Tracks when a user adds a review comment (feedback) to a file in the Changes panel.'; + hasExistingFeedback: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether there was already feedback on this file.' }; + hasSuggestion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback includes a code suggestion.' }; + isFromPRReview: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the feedback was converted from a PR review comment.' }; +}; + +export function logChangesViewReviewCommentAdded(telemetryService: ITelemetryService, data: { hasExistingFeedback: boolean; hasSuggestion: boolean; isFromPRReview: boolean }): void { + telemetryService.publicLog2('vscodeAgents.changesView/reviewCommentAdded', data); +} diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index 4d17842818037..50f9beac5fe90 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 { getColorRegistry, registerColor, transparent } 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 { editorWidgetBorder, editorBackground } from '../../platform/theme/common/colors/editorColors.js'; +import { buttonBackground } from '../../platform/theme/common/colors/inputColors.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: editorWidgetBorder, light: editorWidgetBorder, 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..17db345a8771b 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -5,28 +5,149 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/accountWidget.css'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import './media/accountTitleBarWidget.css'; +import '../../../../workbench/contrib/chat/browser/chatStatus/media/chatStatus.css'; +import Severity from '../../../../base/common/severity.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; 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'; import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { $, append } from '../../../../base/browser/dom.js'; -import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAction } from '../../../../base/common/actions.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { $, addDisposableListener, append, disposableWindowInterval, EventType, getDomNodePagePosition } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction, Separator } from '../../../../base/common/actions.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IUpdateService, State, 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'; +import { ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatStatusDashboard } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { getAccountTitleBarBadgeKey, getAccountTitleBarState } from './accountTitleBarState.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { IAuthenticationAccessService } from '../../../../workbench/services/authentication/browser/authenticationAccessService.js'; +import { IAuthenticationUsageService } from '../../../../workbench/services/authentication/browser/authenticationUsageService.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); +const SessionsTitleBarAccountWidgetAction = 'sessions.action.titleBarAccountWidget'; +const SessionsTitleBarUpdateWidgetAction = 'sessions.action.titleBarUpdateWidget'; +const SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH = 280; + +function shouldHideSessionsTitleBarUpdateWidget(type: StateType): boolean { + return type === StateType.Uninitialized + || type === StateType.Idle + || type === StateType.Disabled + || type === StateType.CheckingForUpdates; +} + +function isPrimarySessionsTitleBarUpdateWidget(type: StateType): boolean { + return type === StateType.AvailableForDownload + || type === StateType.Downloaded + || type === StateType.Ready; +} + +function isBusySessionsTitleBarUpdateWidget(type: StateType): boolean { + return type === StateType.Downloading + || type === StateType.Overwriting + || type === StateType.Updating + || type === StateType.Restarting; +} + +function getSessionsTitleBarUpdateLabel(state: State): string { + switch (state.type) { + case StateType.AvailableForDownload: + return localize('sessionsTitleBarUpdateAvailable', "Update Available"); + case StateType.Downloaded: + return localize('sessionsTitleBarInstallUpdate', "Install Update"); + case StateType.Ready: + return localize('sessionsTitleBarRestartToUpdate', "Restart to Update"); + case StateType.Downloading: + case StateType.Overwriting: + return localize('sessionsTitleBarDownloading', "Downloading..."); + case StateType.Updating: + case StateType.Restarting: + return localize('sessionsTitleBarInstalling', "Installing..."); + default: + return localize('sessionsTitleBarUpdate', "Update"); + } +} + +function getSessionsTitleBarUpdateAriaLabel(state: State): string { + switch (state.type) { + case StateType.AvailableForDownload: + return localize('sessionsTitleBarUpdateAvailableAria', "Update available"); + case StateType.Downloaded: + return localize('sessionsTitleBarInstallUpdateAria', "Install downloaded update"); + case StateType.Ready: + return localize('sessionsTitleBarRestartToUpdateAria', "Restart to apply update"); + case StateType.Downloading: + case StateType.Overwriting: + return localize('sessionsTitleBarDownloadingAria', "Update download in progress"); + case StateType.Updating: + case StateType.Restarting: + return localize('sessionsTitleBarInstallingAria', "Update install in progress"); + default: + return localize('sessionsTitleBarUpdateAria', "Update"); + } +} + +async function runSessionsUpdateAction( + state: State, + updateService: IUpdateService, + openerService: IOpenerService, + productService: IProductService, + dialogService: IDialogService, + hostService: IHostService, +): Promise { + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + const { confirmed } = await dialogService.confirm({ + message: localize('sessionsUpdateFromVSCode.title', "Update from VS Code"), + detail: localize('sessionsUpdateFromVSCode.detail', "This will close the Agents app and open VS Code so you can install the update.\n\nLaunch Agents again after the update is complete."), + primaryButton: localize('sessionsUpdateFromVSCode.open', "Close and Open VS Code"), + }); + + if (confirmed) { + await openerService.open(URI.from({ + scheme: productService.urlProtocol, + query: 'windowId=_blank', + }), { openExternal: true }); + await hostService.close(); + } + + return; + } + + if (state.type === StateType.AvailableForDownload) { + await updateService.downloadUpdate(true); + return; + } + + if (state.type === StateType.Ready) { + await updateService.quitAndInstall(); + return; + } + + if (state.type === StateType.Downloaded) { + await updateService.applyUpdate(); + } +} // Sign In (shown when signed out) registerAction2(class extends Action2 { @@ -64,7 +185,33 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor): Promise { const defaultAccountService = accessor.get(IDefaultAccountService); - await defaultAccountService.signOut(); + const dialogService = accessor.get(IDialogService); + const authenticationService = accessor.get(IAuthenticationService); + const authenticationUsageService = accessor.get(IAuthenticationUsageService); + const authenticationAccessService = accessor.get(IAuthenticationAccessService); + const defaultAccount = await defaultAccountService.getDefaultAccount(); + if (!defaultAccount) { + return; + } + + const providerId = defaultAccount.authenticationProvider.id; + const accountLabel = defaultAccount.accountName; + const { confirmed } = await dialogService.confirm({ + type: Severity.Info, + message: localize('agenticSignOutMessage', "Sign out of the Agents app?"), + detail: localize('agenticSignOutDetail', "This will sign out '{0}' from the Agents app.", accountLabel), + primaryButton: localize({ key: 'agenticSignOutButton', comment: ['&& denotes a mnemonic'] }, "&&Sign Out") + }); + + if (!confirmed) { + return; + } + + const allSessions = await authenticationService.getSessions(providerId); + const sessions = allSessions.filter(session => session.account.label === accountLabel); + await Promise.all(sessions.map(session => authenticationService.removeSession(providerId, session.id))); + authenticationUsageService.removeAccountUsage(providerId, accountLabel); + authenticationAccessService.removeAllowedExtensions(providerId, accountLabel); } }); @@ -81,176 +228,409 @@ MenuRegistry.appendMenuItem(AccountMenu, { // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); -class AccountWidget extends ActionViewItem { - - private accountButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); +class TitleBarAccountWidget extends BaseActionViewItem { + + private container: HTMLElement | undefined; + private iconElement: HTMLElement | undefined; + private labelElement: HTMLElement | undefined; + private badgeElement: HTMLElement | undefined; + private accountName: string | undefined; + private accountProviderLabel: string | undefined; + private isAccountLoading = true; + private accountRequestCounter = 0; + private lastState: ReturnType; + private isMenuVisible = false; + private lastBadgeKey: string | undefined; + private dismissedBadgeKey: string | undefined; + private readonly copilotDashboardStore = this._register(new MutableDisposable()); + private readonly clickPanelDisposable = this._register(new MutableDisposable()); constructor( action: IAction, - options: IBaseActionViewItemOptions, + options: IBaseActionViewItemOptions | undefined, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, ) { - super(undefined, action, { ...options, icon: false, label: false }); - } + super(undefined, action, options); + this.lastState = getAccountTitleBarState({ + isAccountLoading: true, + entitlement: this.chatEntitlementService.entitlement, + sentiment: this.chatEntitlementService.sentiment, + quotas: this.chatEntitlementService.quotas, + }); - protected override getTooltip(): string | undefined { - return undefined; + this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.refreshAccount())); + this._register(this.chatEntitlementService.onDidChangeEntitlement(() => this.renderState())); + this._register(this.chatEntitlementService.onDidChangeSentiment(() => this.renderState())); + this._register(this.chatEntitlementService.onDidChangeQuotaExceeded(() => this.renderState())); + this._register(this.chatEntitlementService.onDidChangeQuotaRemaining(() => this.renderState())); + this.refreshAccount(); } override render(container: HTMLElement): void { super.render(container); - container.classList.add('account-widget', 'sidebar-action'); - - // Account button (left) - const accountContainer = append(container, $('.account-widget-account')); - this.accountButton = this.viewItemDisposables.add(new Button(accountContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); - this.updateAccountButton(); - this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + this.container = container; + container.classList.add('sessions-account-titlebar-widget'); - this.viewItemDisposables.add(this.accountButton.onDidClick(e => { - e?.preventDefault(); - e?.stopPropagation(); - this.showAccountMenu(this.accountButton!.element); - })); + this.iconElement = append(container, $('.sessions-account-titlebar-widget-icon')); + this.labelElement = append(container, $('span.sessions-account-titlebar-widget-label')); + this.badgeElement = append(container, $('span.sessions-account-titlebar-widget-badge')); + + this.renderState(); + } + + override onClick(): void { + if (!this.container) { + return; + } + + this.showCombinedPanel(); + } + + private async refreshAccount(): Promise { + const requestId = ++this.accountRequestCounter; + this.isAccountLoading = true; + this.renderState(); + + const account = await this.defaultAccountService.getDefaultAccount(); + if (requestId !== this.accountRequestCounter) { + return; + } + + this.accountName = account?.accountName; + this.accountProviderLabel = account?.authenticationProvider.name; + this.isAccountLoading = false; + this.renderState(); + } + + private renderState(): void { + if (!this.container || !this.iconElement || !this.labelElement || !this.badgeElement) { + return; + } + + const state = getAccountTitleBarState({ + isAccountLoading: this.isAccountLoading, + accountName: this.accountName, + accountProviderLabel: this.accountProviderLabel, + entitlement: this.chatEntitlementService.entitlement, + sentiment: this.chatEntitlementService.sentiment, + quotas: this.chatEntitlementService.quotas, + }); + this.lastState = state; + + this.container.classList.remove('kind-default', 'kind-accent', 'kind-warning', 'kind-prominent'); + this.container.classList.add(`kind-${state.kind}`); + this.container.classList.toggle('menu-visible', this.isMenuVisible); + this.container.setAttribute('aria-label', state.ariaLabel); + + const badgeKey = getAccountTitleBarBadgeKey(state); + if (badgeKey !== this.lastBadgeKey) { + this.lastBadgeKey = badgeKey; + this.dismissedBadgeKey = undefined; + } + + const shouldShowDotBadge = !!badgeKey && badgeKey !== this.dismissedBadgeKey; + const titleBarIcon = state.dotBadge ? Codicon.account : state.icon; + + this.iconElement.className = `sessions-account-titlebar-widget-icon ${ThemeIcon.asClassName(titleBarIcon)}`; + this.labelElement.textContent = ''; + this.badgeElement.textContent = ''; + this.badgeElement.classList.toggle('dot-badge', shouldShowDotBadge); + this.badgeElement.classList.toggle('dot-badge-warning', shouldShowDotBadge && state.dotBadge === 'warning'); + this.badgeElement.classList.toggle('dot-badge-error', shouldShowDotBadge && state.dotBadge === 'error'); + this.badgeElement.style.display = shouldShowDotBadge ? '' : 'none'; + } + + private getHoverTarget(): { targetElements: HTMLElement[]; x: number } { + const { left, width } = getDomNodePagePosition(this.container!); + return { + targetElements: [this.container!], + x: left + width - SESSIONS_ACCOUNT_TITLEBAR_PANEL_WIDTH, + }; + } + + private showCombinedPanel(): void { + if (!this.container) { + return; + } + + if (this.isMenuVisible) { + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + return; + } + + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + + const panelStore = new DisposableStore(); + this.clickPanelDisposable.value = panelStore; + + const badgeKey = getAccountTitleBarBadgeKey(this.lastState); + if (badgeKey) { + this.dismissedBadgeKey = badgeKey; + } + + this.isMenuVisible = true; + this.container.classList.add('menu-visible'); + this.renderState(); + + panelStore.add({ + dispose: () => { + this.isMenuVisible = false; + this.container?.classList.remove('menu-visible'); + this.renderState(); + } + }); + + const panelContent = this.createCombinedPanelContent(panelStore); + const hoverWidget = this.hoverService.showInstantHover({ + content: panelContent, + target: this.getHoverTarget(), + additionalClasses: ['sessions-account-titlebar-panel-hover'], + position: { hoverPosition: HoverPosition.BELOW }, + persistence: { sticky: true, hideOnHover: false }, + appearance: { showPointer: false, skipFadeInAnimation: true, maxHeightRatio: 0.8 }, + }, true); + + if (hoverWidget) { + panelStore.add(hoverWidget); + } + + panelStore.add(disposableWindowInterval(mainWindow, () => { + if (!panelContent.isConnected || hoverWidget?.isDisposed) { + this.clickPanelDisposable.clear(); + } + }, 500)); + } + + private createCombinedPanelContent(panelStore: DisposableStore): HTMLElement { + const panel = $('div.sessions-account-titlebar-panel'); + const headerSection = append(panel, $('.sessions-account-titlebar-panel-header')); + const title = append(headerSection, $('div.sessions-account-titlebar-panel-title')); + title.textContent = this.getPanelHeaderLabel(); + const headerActions = this.getHeaderActions(); + if (headerActions.length > 0) { + const headerActionsContainer = append(headerSection, $('.sessions-account-titlebar-panel-header-actions')); + for (const action of headerActions) { + const button = append(headerActionsContainer, $('button.sessions-account-titlebar-panel-header-action', { type: 'button' })) as HTMLButtonElement; + button.disabled = !action.enabled; + button.setAttribute('aria-label', action.tooltip || action.label); + button.title = action.tooltip || action.label; + button.classList.add(...ThemeIcon.asClassNameArray(this.getHeaderActionIcon(action))); + + panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { + event.preventDefault(); + event.stopPropagation(); + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + await Promise.resolve(action.run()); + })); + } + } + + const actions = this.getPanelActions(); + if (actions.length > 0) { + const actionsSection = append(panel, $('.sessions-account-titlebar-panel-actions')); + let lastWasSeparator = true; + + for (const action of actions) { + if (action instanceof Separator) { + if (!lastWasSeparator) { + append(actionsSection, $('.sessions-account-titlebar-panel-separator')); + lastWasSeparator = true; + } + continue; + } + + lastWasSeparator = false; + const button = append(actionsSection, $('button.sessions-account-titlebar-panel-action', { type: 'button' })) as HTMLButtonElement; + button.disabled = !action.enabled; + button.setAttribute('aria-label', action.tooltip || action.label); + button.classList.toggle('checked', !!action.checked); + append(button, ...renderLabelWithIcons(action.label)); + + panelStore.add(addDisposableListener(button, EventType.CLICK, async event => { + event.preventDefault(); + event.stopPropagation(); + this.hoverService.hideHover(true); + this.clickPanelDisposable.clear(); + await Promise.resolve(action.run()); + })); + } + } + + const contentSection = append(panel, $('.sessions-account-titlebar-panel-content')); + if (this.shouldShowCopilotDashboardHover()) { + append(contentSection, this.createCopilotHoverContent()); + } else if (!this.isAccountLoading) { + const summary = append(contentSection, $('.sessions-account-titlebar-panel-summary')); + summary.textContent = this.lastState.ariaLabel; + } + + return panel; + } + + private getPanelHeaderLabel(): string { + if (this.accountName) { + return localize('signedInAsHeader', "Signed in as {0}", this.accountName); + } + + if (this.isAccountLoading) { + return localize('loadingAccountHeader', "Loading Account..."); + } + + return localize('accountMenuHeaderFallback', "Account"); } - private showAccountMenu(anchor: HTMLElement): void { + private getHeaderActions(): IAction[] { const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); - const actions: IAction[] = []; - fillInActionBarActions(menu.getActions(), actions); + const rawActions: IAction[] = []; + fillInActionBarActions(menu.getActions(), rawActions); menu.dispose(); - this.contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => actions, + const settingsAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.openSettings'); + const signOutAction = rawActions.find(action => !(action instanceof Separator) && action.id === 'workbench.action.agenticSignOut'); + + return [settingsAction, signOutAction].filter((action): action is IAction => !!action); + } + + private getPanelActions(): IAction[] { + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const rawActions: IAction[] = []; + fillInActionBarActions(menu.getActions(), rawActions); + menu.dispose(); + + return rawActions.filter(action => { + if (action instanceof Separator) { + return true; + } + + // Hide sign-in while account is still loading + if (this.isAccountLoading && action.id === 'workbench.action.agenticSignIn') { + return false; + } + + return action.id !== 'workbench.action.agenticSignOut' + && action.id !== 'workbench.action.openSettings' + && !action.id.startsWith('update.'); }); } - private async updateAccountButton(): Promise { - if (!this.accountButton) { - return; + private getHeaderActionIcon(action: IAction): ThemeIcon { + switch (action.id) { + case 'workbench.action.openSettings': + return Codicon.settingsGear; + case 'workbench.action.agenticSignOut': + return Codicon.signOut; + default: + return Codicon.circleLargeFilled; } - this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; - this.accountButton.enabled = false; - const account = await this.defaultAccountService.getDefaultAccount(); - this.accountButton.enabled = true; - this.accountButton.label = account - ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` - : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } + private shouldShowCopilotDashboardHover(): boolean { + return !this.chatEntitlementService.sentiment.hidden && !!this.accountName; + } - override onClick(): void { - // Handled by custom click handlers + private createCopilotHoverContent(): HTMLElement { + const store = new DisposableStore(); + this.copilotDashboardStore.value = store; + const dashboardElement = ChatStatusDashboard.instantiateInContents(this.instantiationService, store, { + disableInlineSuggestionsSettings: true, + disableModelSelection: true, + disableProviderOptions: true, + disableCompletionsSnooze: true, + disableContributions: true, + }); + + store.add(disposableWindowInterval(mainWindow, () => { + if (!dashboardElement.isConnected) { + store.dispose(); + } + }, 2000)); + + return dashboardElement; } } -class UpdateWidget extends ActionViewItem { +class TitleBarUpdateWidget extends BaseActionViewItem { - private updateButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); + private container: HTMLElement | undefined; + private labelElement: HTMLElement | undefined; + private readonly updateHoverWidget: UpdateHoverWidget; + private readonly hoverAttachment = this._register(new MutableDisposable()); constructor( action: IAction, - options: IBaseActionViewItemOptions, + options: IBaseActionViewItemOptions | undefined, @IUpdateService private readonly updateService: IUpdateService, + @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 }); - } - - protected override getTooltip(): string | undefined { - return undefined; + super(undefined, action, options); + this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); + this._register(this.updateService.onStateChange(() => this.renderState())); } override render(container: HTMLElement): void { super.render(container); - container.classList.add('update-widget', 'sidebar-action'); - - 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())); - } + this.container = container; + container.classList.add('sessions-update-titlebar-widget'); - private isUpdateReady(): boolean { - return this.updateService.state.type === StateType.Ready; - } + this.labelElement = append(container, $('span.sessions-update-titlebar-widget-label')); + this.hoverAttachment.value = this.updateHoverWidget.attachTo(container); - 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; + this.renderState(); } - private updateUpdateButton(): void { - if (!this.updateButton) { + override onClick(): void { + const state = this.updateService.state; + if (shouldHideSessionsTitleBarUpdateWidget(state.type) || isBusySessionsTitleBarUpdateWidget(state.type)) { 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")}`; - } + void runSessionsUpdateAction( + state, + this.updateService, + this.openerService, + this.productService, + this.dialogService, + this.hostService, + ); } - private getUpdateProgressMessage(type: StateType): string { - switch (type) { - case StateType.CheckingForUpdates: - return localize('checkingForUpdates', "Checking for Updates..."); - case StateType.Downloading: - return localize('downloadingUpdate', "Downloading Update..."); - case StateType.Downloaded: - return localize('installingUpdate', "Installing Update..."); - case StateType.Updating: - return localize('updatingApp', "Updating..."); - case StateType.Overwriting: - return localize('overwritingUpdate', "Downloading Update..."); - default: - return localize('updating', "Updating..."); + private renderState(): void { + if (!this.container || !this.labelElement) { + return; } - } - private async update(): Promise { - await this.updateService.quitAndInstall(); - } + const state = this.updateService.state; + const hidden = shouldHideSessionsTitleBarUpdateWidget(state.type); + const busy = isBusySessionsTitleBarUpdateWidget(state.type); + const primary = isPrimarySessionsTitleBarUpdateWidget(state.type); + + this.container.classList.toggle('hidden', hidden); + this.container.classList.toggle('disabled', busy); + this.container.classList.toggle('primary-state', primary); + this.container.classList.toggle('busy-state', busy); + + if (hidden) { + this.container.removeAttribute('aria-label'); + this.labelElement.textContent = ''; + return; + } - override onClick(): void { - // Handled by custom click handlers + this.container.setAttribute('aria-label', getSessionsTitleBarUpdateAriaLabel(state)); + this.labelElement.textContent = getSessionsTitleBarUpdateLabel(state); } } @@ -266,59 +646,53 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu ) { super(); - const sessionsAccountWidgetAction = 'sessions.action.accountWidget'; - this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsAccountWidgetAction, (action, options) => { - return instantiationService.createInstance(AccountWidget, action, options); + // Titlebar update widget (to the right of separator, left of account badge) + this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarUpdateWidgetAction, (action, options) => { + return instantiationService.createInstance(TitleBarUpdateWidget, 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 { constructor() { super({ - id: sessionsAccountWidgetAction, - title: localize2('sessionsAccountWidget', 'Sessions Account'), + id: SessionsTitleBarUpdateWidgetAction, + title: localize2('agentsUpdateTitleBar', "Agents Update"), menu: { - id: Menus.SidebarFooter, + id: Menus.TitleBarRightLayout, group: 'navigation', - order: 1, + order: 99, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), } }); } + async run(): Promise { // Handled by the custom view item } })); + // Titlebar account widget (rightmost in titlebar) + this._register(actionViewItemService.register(Menus.TitleBarRightLayout, SessionsTitleBarAccountWidgetAction, (action, options) => { + return instantiationService.createInstance(TitleBarAccountWidget, action, options); + }, undefined)); + this._register(registerAction2(class extends Action2 { constructor() { super({ - id: sessionsUpdateWidgetAction, - title: localize2('sessionsUpdateWidget', 'Sessions Update'), + id: SessionsTitleBarAccountWidgetAction, + title: localize2('agentsAccountStatusTitleBar', "Agents Account and Status"), menu: { - id: Menus.SidebarFooter, + id: Menus.TitleBarRightLayout, 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), - ) + order: 100, } }); } + async run(): Promise { // Handled by the custom view item } })); + } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.ts b/src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.ts new file mode 100644 index 0000000000000..fa93bb3d7d062 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/accountTitleBarState.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 { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { ChatEntitlement, IChatSentiment, IQuotaSnapshot } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; + +export type AccountTitleBarStateSource = 'account' | 'copilot'; +export type AccountTitleBarStateKind = 'default' | 'accent' | 'warning' | 'prominent'; + +export interface IAccountTitleBarStateContext { + readonly isAccountLoading: boolean; + readonly accountName?: string; + readonly accountProviderLabel?: string; + readonly entitlement: ChatEntitlement; + readonly sentiment: Pick; + readonly quotas: { + readonly chat?: IQuotaSnapshot; + readonly completions?: IQuotaSnapshot; + }; +} + +export interface IAccountTitleBarState { + readonly source: AccountTitleBarStateSource; + readonly kind: AccountTitleBarStateKind; + readonly icon: ThemeIcon; + readonly label: string; + readonly ariaLabel: string; + readonly badge?: string; + readonly dotBadge?: 'warning' | 'error'; + readonly revealLabelOnHover?: boolean; +} + +export function getAccountTitleBarBadgeKey(state: IAccountTitleBarState): string | undefined { + if (!state.dotBadge) { + return undefined; + } + + return `${state.source}:${state.dotBadge}:${state.badge ?? ''}`; +} + +export function getAccountTitleBarState(context: IAccountTitleBarStateContext): IAccountTitleBarState { + if (context.isAccountLoading) { + return { + source: 'account', + kind: 'default', + icon: ThemeIcon.modify(Codicon.loading, 'spin'), + label: localize('loadingAccount', "Loading Account..."), + ariaLabel: localize('loadingAccountAria', "Loading account"), + revealLabelOnHover: true, + }; + } + + const copilotState = getCopilotPresentation(context.entitlement, context.sentiment, context.quotas); + if (copilotState) { + return copilotState; + } + + if (context.accountName) { + return { + source: 'account', + kind: 'default', + icon: Codicon.account, + label: context.accountName, + revealLabelOnHover: true, + ariaLabel: context.accountProviderLabel + ? localize('accountSignedInAria', "Signed in as {0} with {1}", context.accountName, context.accountProviderLabel) + : localize('accountSignedInAriaNameOnly', "Signed in as {0}", context.accountName), + }; + } + + return { + source: 'account', + kind: 'prominent', + icon: Codicon.account, + label: localize('signInLabel', "Sign In"), + ariaLabel: localize('signInAria', "Sign in to your account"), + }; +} + +function getCopilotPresentation( + entitlement: ChatEntitlement, + sentiment: Pick, + quotas: { readonly chat?: IQuotaSnapshot; readonly completions?: IQuotaSnapshot } +): IAccountTitleBarState | undefined { + if (sentiment.hidden) { + return undefined; + } + + if (entitlement === ChatEntitlement.Unknown) { + return { + source: 'copilot', + kind: 'prominent', + icon: Codicon.account, + label: localize('agentsSignedOut', "Agents Signed Out"), + ariaLabel: localize('agentsSignedOutAria', "Agents is signed out"), + }; + } + + if (sentiment.disabled || sentiment.untrusted) { + return { + source: 'copilot', + kind: 'warning', + icon: Codicon.account, + label: localize('copilotUnavailable', "Copilot Unavailable"), + ariaLabel: sentiment.untrusted + ? localize('copilotUnavailableUntrustedAria', "GitHub Copilot is unavailable in untrusted workspaces") + : localize('copilotUnavailableDisabledAria', "GitHub Copilot is disabled"), + }; + } + + const chatQuotaExceeded = quotas.chat?.percentRemaining === 0; + const completionsQuotaExceeded = quotas.completions?.percentRemaining === 0; + if (entitlement === ChatEntitlement.Free && (chatQuotaExceeded || completionsQuotaExceeded)) { + return { + source: 'copilot', + kind: 'warning', + icon: Codicon.account, + label: localize('copilotQuotaReached', "Quota Reached"), + dotBadge: 'error', + ariaLabel: getQuotaReachedAriaLabel(chatQuotaExceeded, completionsQuotaExceeded), + }; + } + + const remainingPercent = getLowestPositivePercent(quotas.chat, quotas.completions); + if (entitlement === ChatEntitlement.Free && typeof remainingPercent === 'number' && remainingPercent <= 25) { + return { + source: 'copilot', + kind: remainingPercent <= 10 ? 'warning' : 'accent', + icon: Codicon.account, + label: localize('copilotTokensRemaining', "Tokens Remaining"), + badge: `${remainingPercent}%`, + dotBadge: remainingPercent <= 10 ? 'error' : 'warning', + ariaLabel: localize('copilotTokensRemainingAria', "{0}% GitHub Copilot tokens remaining", remainingPercent), + }; + } + + return undefined; +} + +function getLowestPositivePercent(...quotas: Array): number | undefined { + let lowest: number | undefined; + for (const quota of quotas) { + if (typeof quota?.percentRemaining !== 'number' || quota.percentRemaining <= 0) { + continue; + } + + lowest = typeof lowest === 'number' + ? Math.min(lowest, quota.percentRemaining) + : quota.percentRemaining; + } + + return lowest; +} + +function getQuotaReachedAriaLabel(chatQuotaExceeded: boolean, completionsQuotaExceeded: boolean): string { + if (chatQuotaExceeded && completionsQuotaExceeded) { + return localize('copilotAllQuotaReachedAria', "GitHub Copilot chat and inline suggestion quota reached"); + } + + if (chatQuotaExceeded) { + return localize('copilotChatQuotaReachedAria', "GitHub Copilot chat quota reached"); + } + + return localize('copilotCompletionsQuotaReachedAria', "GitHub Copilot inline suggestion quota reached"); +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css new file mode 100644 index 0000000000000..632e66f36e2e2 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -0,0 +1,386 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-workbench .sessions-account-titlebar-widget { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + position: relative; + height: 22px; + width: 22px; + min-width: 22px; + background: transparent; + border-radius: var(--vscode-cornerRadius-medium); + color: inherit; + cursor: pointer; + -webkit-app-region: no-drag; + overflow: hidden; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget:hover, +.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .sessions-account-titlebar-widget:active, +.agent-sessions-workbench .sessions-account-titlebar-widget.menu-visible, +.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget.menu-visible { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget-icon { + flex: 0 0 auto; + font-size: 16px; + color: inherit; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget-label { + display: none; +} + +/* Update widget in titlebar */ + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .actions-container { + gap: 0; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget { + display: flex; + align-items: center; + height: 22px; + min-width: 0; + padding: 0 8px; + border-radius: var(--vscode-cornerRadius-medium); + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + -webkit-app-region: no-drag; + overflow: hidden; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.hidden { + display: none; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:hover, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:focus-within { + background: var(--vscode-toolbar-hoverBackground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget:active, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:hover, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:focus-within, +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.primary-state:active { + background: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget.busy-state { + cursor: default; + color: var(--vscode-descriptionForeground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-right-layout-container .monaco-action-bar .action-item.sessions-update-titlebar-widget .sessions-update-titlebar-widget-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + line-height: 16px; + color: inherit; +} + +/* Badge on account icon */ + +.agent-sessions-workbench .sessions-account-titlebar-widget-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + border-radius: 8px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge { + position: absolute; + top: 3px; + right: 3px; + min-width: 0; + width: 6px; + height: 6px; + padding: 0; + border-radius: 50%; + border: 1px solid var(--vscode-titleBar-activeBackground); + background: var(--vscode-editorWarning-foreground); + color: transparent; + pointer-events: none; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge-warning { + background: var(--vscode-editorWarning-foreground) !important; +} + +.agent-sessions-workbench .sessions-account-titlebar-widget-badge.dot-badge-error { + background: var(--vscode-editorError-foreground) !important; +} + +/* Kind-based styling */ + +.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-icon, +.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-label { + color: var(--vscode-statusBarItem-warningForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-widget.kind-prominent .sessions-account-titlebar-widget-badge:not(.dot-badge), +.agent-sessions-workbench .sessions-account-titlebar-widget.kind-warning .sessions-account-titlebar-widget-badge:not(.dot-badge) { + background: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-widget.kind-accent .sessions-account-titlebar-widget-badge:not(.dot-badge) { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +/* Panel (hover popup) */ + +.agent-sessions-workbench .sessions-account-titlebar-panel { + display: flex; + flex-direction: column; + width: 280px; + max-width: 280px; + color: var(--vscode-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header { + display: flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 6px 8px 0; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + line-height: 18px; + color: var(--vscode-descriptionForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + margin-left: auto; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: var(--vscode-cornerRadius-medium); + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: default; + font-size: 14px !important; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:active { + background: var(--vscode-toolbar-activeBackground); + color: var(--vscode-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + background: var(--vscode-list-focusBackground); + color: var(--vscode-list-focusForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-header-action:disabled { + opacity: 0.5; + cursor: default; +} + +.monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover, +.workbench-hover-container.locked .monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover, +.workbench-hover-container:focus-within.locked .monaco-hover.workbench-hover.sessions-account-titlebar-panel-hover { + outline: none; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-content { + display: flex; + flex-direction: column; + min-height: 0; + padding-top: 4px; + border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-summary { + padding: 12px 16px; + font-size: 12px; + line-height: 1.4; + color: var(--vscode-descriptionForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-actions { + display: flex; + flex-direction: column; + gap: 0; + padding: 4px 0; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-separator { + height: 1px; + margin: 5px 0; + background: var(--vscode-menu-separatorBackground, var(--vscode-disabledForeground)); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action { + display: flex; + align-items: center; + width: auto; + box-sizing: border-box; + height: 24px; + margin: 0 4px; + padding: 0 16px; + border: none; + border-radius: var(--vscode-cornerRadius-medium); + background: transparent; + color: inherit; + font-size: 12px; + line-height: 1; + text-align: left; + cursor: default; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action:hover { + background: var(--vscode-list-hoverBackground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.checked, +.agent-sessions-workbench .sessions-account-titlebar-panel-action:active { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + background: var(--vscode-list-focusBackground); + color: var(--vscode-list-focusForeground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action:disabled { + opacity: 0.6; + cursor: default; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action { + margin: 4px 8px; + padding: 0 12px; + justify-content: center; + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-weight: 500; + cursor: pointer; +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:hover { + background: var(--vscode-button-hoverBackground); + color: var(--vscode-button-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action.checked, +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:active { + background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); + color: var(--vscode-button-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:focus-visible { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.agent-sessions-workbench .sessions-account-titlebar-panel-action.primary-action:disabled { + opacity: 0.5; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + cursor: default; +} + +/* Motion animations */ + +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget { + transition: background-color 150ms ease-out; +} + +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget-icon, +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget-label, +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget-badge, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget-icon, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget-label, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget-badge { + transition: opacity 150ms ease-out, transform 150ms ease-out, background-color 150ms ease-out, color 150ms ease-out; +} + +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget:hover .sessions-account-titlebar-widget-badge, +.monaco-enable-motion .agent-sessions-workbench .sessions-account-titlebar-widget.menu-visible .sessions-account-titlebar-widget-badge, +.monaco-enable-motion .agent-sessions-workbench .action-item:focus-within .sessions-account-titlebar-widget .sessions-account-titlebar-widget-badge, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget:hover .sessions-account-titlebar-widget-badge, +.agent-sessions-workbench.monaco-enable-motion .sessions-account-titlebar-widget.menu-visible .sessions-account-titlebar-widget-badge, +.agent-sessions-workbench.monaco-enable-motion .action-item:focus-within .sessions-account-titlebar-widget .sessions-account-titlebar-widget-badge { + transform: scale(1.04); +} + +/* Reduced motion */ + +.monaco-reduce-motion .agent-sessions-workbench .sessions-account-titlebar-widget, +.monaco-reduce-motion .agent-sessions-workbench .sessions-account-titlebar-widget-icon, +.monaco-reduce-motion .agent-sessions-workbench .sessions-account-titlebar-widget-label, +.monaco-reduce-motion .agent-sessions-workbench .sessions-account-titlebar-widget-badge, +.agent-sessions-workbench.monaco-reduce-motion .sessions-account-titlebar-widget, +.agent-sessions-workbench.monaco-reduce-motion .sessions-account-titlebar-widget-icon, +.agent-sessions-workbench.monaco-reduce-motion .sessions-account-titlebar-widget-label, +.agent-sessions-workbench.monaco-reduce-motion .sessions-account-titlebar-widget-badge { + transition-duration: 0ms !important; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 01bdd2c100b03..99c43c044da9b 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,201 @@ 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; +} + +.account-widget-update:has(.account-widget-update-button.hidden) { + display: none; +} + +/* Copilot Status Button */ +.account-widget-copilot-status { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.account-widget-copilot-status.hidden { + display: none; +} + +.account-widget-copilot-status .account-widget-copilot-status-button { + width: auto; + max-width: none; + padding: 4px 6px; +} + +/* Chat status dashboard shown from sidebar footer */ +.monaco-hover .chat-status-bar-entry-tooltip { + font-size: 12px; + max-width: 320px; + padding: 4px 6px; + overflow: hidden; +} + +.monaco-hover .chat-status-bar-entry-tooltip div.header { + font-size: 12px; + margin-bottom: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-hover .chat-status-bar-entry-tooltip div.description { + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-hover .chat-status-bar-entry-tooltip hr { + margin-top: 10px; + margin-bottom: 10px; +} + +.monaco-hover .chat-status-bar-entry-tooltip .quota-indicator { + margin-bottom: 4px; +} + +.monaco-hover .chat-status-bar-entry-tooltip .contribution .body { + font-size: 11px; + overflow: hidden; + min-width: 0; +} + +.monaco-hover .chat-status-bar-entry-tooltip .contribution .body .description, +.monaco-hover .chat-status-bar-entry-tooltip .contribution .body .detail-item { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.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..ebe155ca5c35c --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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, + private readonly stateProvider?: () => State, + ) { } + + 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.stateProvider?.() ?? 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/accountTitleBarState.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.test.ts new file mode 100644 index 0000000000000..a97342c34ed1d --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountTitleBarState.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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatEntitlement } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { getAccountTitleBarBadgeKey, getAccountTitleBarState, IAccountTitleBarStateContext } from '../../browser/accountTitleBarState.js'; + +suite('Sessions - Account Title Bar State', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createState(overrides: Partial = {}): IAccountTitleBarStateContext { + return { + isAccountLoading: false, + accountName: 'lee@example.com', + accountProviderLabel: 'GitHub', + entitlement: ChatEntitlement.Pro, + sentiment: {}, + quotas: {}, + ...overrides, + }; + } + + test('shows low token badge for Copilot Free users', () => { + const state = getAccountTitleBarState(createState({ + entitlement: ChatEntitlement.Free, + quotas: { chat: { total: 100, remaining: 10, percentRemaining: 10, overageEnabled: false, overageCount: 0, unlimited: false } }, + })); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + badge: state.badge, + dotBadge: state.dotBadge, + kind: state.kind, + }, { + source: 'copilot', + label: 'Tokens Remaining', + badge: '10%', + dotBadge: 'error', + kind: 'warning', + }); + + assert.strictEqual(getAccountTitleBarBadgeKey(state), 'copilot:error:10%'); + }); + + test('shows warning dot badge for low but non-critical tokens', () => { + const state = getAccountTitleBarState(createState({ + entitlement: ChatEntitlement.Free, + quotas: { chat: { total: 100, remaining: 20, percentRemaining: 20, overageEnabled: false, overageCount: 0, unlimited: false } }, + })); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + badge: state.badge, + dotBadge: state.dotBadge, + kind: state.kind, + }, { + source: 'copilot', + label: 'Tokens Remaining', + badge: '20%', + dotBadge: 'warning', + kind: 'accent', + }); + }); + + test('shows quota reached warning when free quota is exhausted', () => { + const state = getAccountTitleBarState(createState({ + entitlement: ChatEntitlement.Free, + quotas: { completions: { total: 100, remaining: 0, percentRemaining: 0, overageEnabled: false, overageCount: 0, unlimited: false } }, + })); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + dotBadge: state.dotBadge, + kind: state.kind, + }, { + source: 'copilot', + label: 'Quota Reached', + dotBadge: 'error', + kind: 'warning', + }); + + assert.strictEqual(getAccountTitleBarBadgeKey(state), 'copilot:error:'); + }); + + test('falls back to signed-in account label when no higher-priority state exists', () => { + const state = getAccountTitleBarState(createState()); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + kind: state.kind, + revealLabelOnHover: state.revealLabelOnHover, + }, { + source: 'account', + label: 'lee@example.com', + kind: 'default', + revealLabelOnHover: true, + }); + }); + + test('reveals loading account label only on hover', () => { + const state = getAccountTitleBarState(createState({ + isAccountLoading: true, + accountName: undefined, + accountProviderLabel: undefined, + entitlement: ChatEntitlement.Unknown, + })); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + kind: state.kind, + revealLabelOnHover: state.revealLabelOnHover, + }, { + source: 'account', + label: 'Loading Account...', + kind: 'default', + revealLabelOnHover: true, + }); + }); + + test('shows sign in state when no account is available', () => { + const state = getAccountTitleBarState(createState({ + accountName: undefined, + accountProviderLabel: undefined, + entitlement: ChatEntitlement.Unknown, + })); + + assert.deepStrictEqual({ + source: state.source, + label: state.label, + kind: state.kind, + }, { + source: 'copilot', + label: 'Agents Signed Out', + kind: 'prominent', + }); + }); +}); 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..e18a4c0a89f6d --- /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, true, 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..2bafcfae2ec31 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'); } } @@ -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..ed6ccc8912ff2 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,52 @@ class AgentFeedbackInputWidget implements IOverlayWidget { this._measureElement.classList.add('agent-feedback-input-measure'); this._domNode.appendChild(this._measureElement); - this._editor.applyFontInfo(this._inputElement); - this._editor.applyFontInfo(this._measureElement); - this._lineHeight = this._editor.getOption(EditorOption.lineHeight); + // 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._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 +144,7 @@ class AgentFeedbackInputWidget implements IOverlayWidget { clearInput(): void { this._inputElement.value = ''; + this._updateActionEnabled(); this._autoSize(); } @@ -93,6 +152,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 +178,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 +195,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 +203,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 +244,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 +254,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 +263,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 +289,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 +338,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 +356,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 +402,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 +425,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 +444,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 +493,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 +549,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 +603,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 +633,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..08d73ad653299 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(); @@ -102,6 +88,11 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid // Header this._headerNode = $('div.agent-feedback-widget-header'); + // Comment icon (decorative, hidden from screen readers) + const commentIcon = renderIcon(Codicon.comment); + commentIcon.setAttribute('aria-hidden', 'true'); + this._headerNode.appendChild(commentIcon); + // Title showing feedback count this._titleNode = $('span.agent-feedback-widget-title'); this._updateTitle(); @@ -115,12 +106,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 +140,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,29 +150,10 @@ 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"); + this._titleNode.textContent = this._commentItems[0].text; } else { this._titleNode.textContent = nls.localize('nComments', "{0} comments", count); } @@ -213,37 +174,265 @@ 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! }; + + itemActions.editAction = new Action( + 'agentFeedback.widget.edit', + nls.localize('editComment', "Edit"), + ThemeIcon.asClassName(Codicon.edit), + true, + (): 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'); + + for (const edit of comment.suggestion?.edits ?? []) { + const editNode = $('div.agent-feedback-widget-suggestion-edit'); + + const header = $('div.agent-feedback-widget-suggestion-header'); + if (edit.range.startLineNumber === edit.range.endLineNumber) { + header.textContent = nls.localize('suggestedChangeLine', "Suggested Change \u2022 Line {0}", edit.range.startLineNumber); + } else { + header.textContent = nls.localize('suggestedChangeLines', "Suggested Change \u2022 Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber); + } + editNode.appendChild(header); + + 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 { + // 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 { + // PR review and code review comments are converted to agent feedback on edit + this._convertToAgentFeedbackWithText(comment, newText); + } + } + + private _stopEditing(comment: ISessionEditorComment, textContainer: HTMLElement, editStore: DisposableStore, actions: ICommentItemActions): void { + editStore.dispose(); + + // Re-enable actions + actions.editAction.enabled = true; + 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 { + this._convertToAgentFeedbackWithText(comment, comment.text); + } + + /** + * Converts a non-agent-feedback comment into an agent feedback item, optionally with edited text. + */ + private _convertToAgentFeedbackWithText(comment: ISessionEditorComment, text: string): 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, + 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 +466,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 +489,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 +522,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 +540,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 +564,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 +601,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 +629,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 +652,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 +674,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 +692,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..dd85dd2da6a94 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -11,9 +11,17 @@ 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'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logChangesViewReviewCommentAdded } from '../../../common/sessionsTelemetry.js'; // --- Types -------------------------------------------------------------------- @@ -23,6 +31,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 +65,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 +92,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 +141,17 @@ 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, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { 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 +165,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. @@ -160,6 +204,12 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + logChangesViewReviewCommentAdded(this._telemetryService, { + hasExistingFeedback: hasExistingForFile, + hasSuggestion: !!suggestion, + isFromPRReview: !!sourcePRReviewCommentId, + }); + return feedback; } @@ -187,6 +237,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 +299,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 +319,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 item = items[nextIndex]; + this.setNavigationAnchor(sessionResource, item.id); + return item; + } - const feedback = feedbackItems[nextIndex]; - this._navigationAnchorBySession.set(key, feedback.id); + 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 +455,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..82ed568e03679 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -7,18 +7,40 @@ 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-editorWidget-border, var(--vscode-contrastBorder))); + box-shadow: var(--vscode-shadow-lg); border-radius: 8px; padding: 4px; + display: flex; + flex-direction: row; + align-items: flex-end; + animation: agentFeedbackInputAppear 0.15s ease-out; } +@keyframes agentFeedbackInputAppear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-feedback-input-widget { + animation: none; + transform: none; + } +} .agent-feedback-input-widget textarea { background-color: var(--vscode-panel-background); border: none; color: var(--vscode-input-foreground); + font: inherit; border-radius: 4px; - padding: 0; + padding: 0 0 0 6px; outline: none; min-width: 150px; max-width: 400px; @@ -28,6 +50,7 @@ word-wrap: break-word; box-sizing: border-box; display: block; + flex: 1; } .agent-feedback-input-widget textarea:focus { @@ -45,4 +68,18 @@ height: 0; overflow: hidden; white-space: pre; + font: inherit; + font-size: 13px; +} + +.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..f9e5985f954c5 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; @@ -35,13 +35,14 @@ /* Arrow pointer pointing left toward the code */ .agent-feedback-widget-arrow { position: absolute; - left: -8px; - top: 12px; + left: -6px; + top: 11px; width: 0; height: 0; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-right: 8px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + display: none; } .agent-feedback-widget.collapsed .agent-feedback-widget-arrow { @@ -52,19 +53,19 @@ content: ''; position: absolute; left: 2px; - top: -7px; + top: -5px; width: 0; height: 0; - border-top: 7px solid transparent; - border-bottom: 7px solid transparent; - border-right: 7px solid var(--vscode-editorWidget-background); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-right: 5px solid var(--vscode-editorWidget-background); } /* Header */ .agent-feedback-widget-header { display: flex; align-items: center; - padding: 8px 10px; + padding: 4px 4px 4px 8px; border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); border-radius: 8px 8px 0 0; overflow: hidden; @@ -84,8 +85,12 @@ /* Title */ .agent-feedback-widget-title { font-weight: 500; + line-height: 12px; color: var(--vscode-foreground); white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } /* Spacer to push buttons to the right */ @@ -98,9 +103,10 @@ display: flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; - border-radius: 4px; + width: 22px; + height: 22px; + min-width: 22px; + border-radius: var(--vscode-cornerRadius-medium); cursor: pointer; color: var(--vscode-foreground); opacity: 0.7; @@ -113,24 +119,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; @@ -148,10 +136,11 @@ .agent-feedback-widget-item { display: flex; flex-direction: column; - padding: 8px 10px; + padding: 4px 4px 8px 8px; 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 +156,62 @@ color: var(--vscode-list-activeSelectionForeground); } +.agent-feedback-widget-item-header { + display: flex; + align-items: center; + 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: 4px; + 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%, var(--vscode-editorWidget-background)); + 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%, var(--vscode-editorWidget-background)); + 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 +222,89 @@ 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; + margin-top: 8px; + padding: 0px 8px 4px 12px; +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-suggestion { + border-left: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground) 50%, transparent); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-suggestion { + border-left: 1px solid color-mix(in srgb, var(--vscode-editorInfo-foreground) 50%, transparent); +} + +.agent-feedback-widget-suggestion-header { + 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: var(--monaco-monospace-font); + font-size: 11px; + line-height: 1.45; + background: var(--vscode-editorWidget-background); +} + /* 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 4px 0 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: inherit; + 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..c814fe0d2491e --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ICodeReviewService, ICodeReviewSuggestion } from '../../../codeReview/browser/codeReviewService.js'; +import { createMockCodeReviewService } from '../../../../../workbench/test/browser/componentFixtures/sessions/mockCodeReviewService.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 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..d042a6eade196 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -13,6 +13,8 @@ import { AgentFeedbackService, IAgentFeedbackService } from '../../browser/agent import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; function r(startLine: number, endLine: number = startLine): Range { return new Range(startLine, 1, endLine, 1); @@ -36,6 +38,7 @@ suite('AgentFeedbackService - Ordering', () => { instantiationService.stub(IChatEditingService, new class extends mock() { }); instantiationService.stub(IAgentSessionsService, new class extends mock() { }); + instantiationService.stub(ITelemetryService, NullTelemetryService); service = store.add(instantiationService.createInstance(AgentFeedbackService)); session = URI.parse('test://session/1'); @@ -197,4 +200,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..f379421612651 --- /dev/null +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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: IsSessionsWindowContext, +}); 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..266aed6819e4c --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesTitleBarWidget.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { logChangesViewToggle } from '../../../common/sessionsTelemetry.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CHANGES_VIEW_CONTAINER_ID } from '../common/changes.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, + @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.activeSessionService.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'); + container.setAttribute('role', 'button'); + + this._rebuildIndicators(); + this._updateActiveState(); + } + + private _updateActiveState(): void { + const isVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this._container?.classList.toggle('toggled', isVisible); + this._container?.setAttribute('aria-pressed', String(isVisible)); + } + + 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.activeSessionService.getSession(resource) : undefined; + const summary = session ? getAgentChangesSummary(session.changes.get()) : 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) { + const label = localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions); + btn.setAttribute('aria-label', label); + this._indicatorDisposables.add(this.hoverService.setupManagedHover( + this._hoverDelegate, btn, label + )); + } else { + btn.setAttribute('aria-label', localize('showChanges', "Show Changes")); + 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: 11, // After Run Script (8), Open in VS Code (9), and Open Terminal (10) + 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 telemetryService = accessor.get(ITelemetryService); + + const isVisible = !layoutService.isVisible(Parts.AUXILIARYBAR_PART); + layoutService.setPartHidden(!isVisible, Parts.AUXILIARYBAR_PART); + if (isVisible) { + paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + } + + logChangesViewToggle(telemetryService, isVisible); + } +}); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts similarity index 78% rename from src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts rename to src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 3e69bba8dfa4f..b3775828a7e77 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 { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../common/changes.js'; +import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { ChangesTitleBarContribution } from './changesTitleBarWidget.js'; +import './changesViewActions.js'; +import './checksActions.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -38,3 +43,5 @@ viewsRegistry.registerViews([{ order: 1, windowVisibility: WindowVisibility.Sessions }], changesViewContainer); + +registerWorkbenchContribution2(ChangesTitleBarContribution.ID, ChangesTitleBarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts new file mode 100644 index 0000000000000..34c4714a391ab --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -0,0 +1,1178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { IObjectTreeElement } from '../../../../base/browser/ui/tree/tree.js'; +import { ActionRunner, IAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; +import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/path.js'; +import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; +import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js'; +import { ResourceLabels } from '../../../../workbench/browser/labels.js'; +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 { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.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 { CIStatusWidget } from './checksWidget.js'; +import { GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../sessions/common/sessionData.js'; +import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; +import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; +import { Color } from '../../../../base/common/color.js'; +import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; +import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; +import { ChecksViewModel } from './checksViewModel.js'; +import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesVersionMode, ChangesViewMode, IsolationMode } from '../common/changes.js'; +import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js'; +import { ChangesViewModel } from './changesViewModel.js'; +import { ResourceTree } from '../../../../base/common/resourceTree.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; + +const $ = dom.$; + +// --- Constants + +const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; + +// --- ButtonBar widget + +class ChangesButtonBarWidget extends Disposable { + constructor( + container: HTMLElement, + viewModel: ChangesViewModel, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, + @IMenuService menuService: IMenuService, + @ICodeReviewService codeReviewService: ICodeReviewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IContextMenuService contextMenuService: IContextMenuService, + @IKeybindingService keybindingService: IKeybindingService, + @ITelemetryService telemetryService: ITelemetryService, + @IHoverService hoverService: IHoverService + ) { + super(); + + const outgoingChangesObs = derived(reader => { + const activeSessionState = viewModel.activeSessionStateObs.read(reader); + return activeSessionState?.outgoingChanges ?? 0; + }); + + const reviewStateObs = derivedOpts<{ isLoading: boolean; commentCount: number | undefined }>({ equalsFn: structuralEquals }, reader => { + const sessionResource = viewModel.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return { isLoading: false, commentCount: undefined }; + } + + const sessionChanges = viewModel.activeSessionChangesObs.read(reader); + const prReviewState = codeReviewService.getPRReviewState(sessionResource).read(reader); + const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded + ? prReviewState.comments.length + : 0; + + let isLoading = false; + let commentCount: number | undefined; + if (sessionChanges && sessionChanges.length > 0) { + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = codeReviewService.getReviewState(sessionResource).read(reader); + + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) { + isLoading = true; + } else { + const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion + ? reviewState.comments.length + : 0; + const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount; + if (totalReviewCommentCount > 0) { + commentCount = totalReviewCommentCount; + } + } + } else if (prReviewCommentCount > 0) { + commentCount = prReviewCommentCount; + } + + return { isLoading, commentCount }; + }); + + this._register(autorun(reader => { + const sessionResource = viewModel.activeSessionResourceObs.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); + const reviewState = reviewStateObs.read(reader); + + reader.store.add(new MenuWorkbenchButtonBar( + container, + MenuId.ChatEditingSessionChangesToolbar, + { + telemetrySource: 'changesView', + disableWhileRunning: true, + menuOptions: sessionResource + ? { args: [sessionResource, agentSessionsService.getSession(sessionResource)?.metadata] } + : { shouldForwardArgs: true }, + buttonConfigProvider: (action) => this._getButtonConfiguration(action, outgoingChanges, reviewState) + }, + menuService, contextKeyService, contextMenuService, keybindingService, telemetryService, hoverService + )); + })); + } + + private _getButtonConfiguration(action: IAction, outgoingChanges: number, reviewState: { isLoading: boolean; commentCount: number | undefined }): { showIcon: boolean; showLabel: boolean; isSecondary?: boolean; customLabel?: string; customClass?: string } | undefined { + if ( + action.id === 'github.copilot.sessions.sync' || + action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR' + ) { + const customLabel = outgoingChanges > 0 + ? `${action.label} ${outgoingChanges}↑` + : action.label; + return { customLabel, showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { + if (reviewState.isLoading) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' }; + } + if (reviewState.commentCount !== undefined) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewState.commentCount), customClass: 'code-review-comments' }; + } + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if ( + action.id === 'chatEditing.viewAllSessionChanges' || + action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR' + ) { + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'agentFeedbackEditor.action.submitActiveSession') { + return { showIcon: false, showLabel: true, isSecondary: false }; + } + if ( + action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR' || + action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge' || + action.id === 'github.copilot.chat.checkoutPullRequestReroute' || + action.id === 'pr.checkoutFromChat' || + action.id === 'github.copilot.sessions.initializeRepository' || + action.id === 'github.copilot.sessions.commit' || + action.id === 'agentSession.markAsDone' + ) { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + + // Unknown actions (e.g. extension-contributed): only hide the label when an icon is present. + if (action instanceof MenuItemAction) { + const icon = action.item.icon; + if (icon) { + // Icon-only button (no forced secondary state so primary/secondary can be inferred). + return { showIcon: true, showLabel: false }; + } + } + + // Fall back to default button behavior for actions without an icon. + return undefined; + } +} + +// --- View Pane + +export class ChangesViewPane extends ViewPane { + + private bodyContainer: HTMLElement | undefined; + private welcomeContainer: HTMLElement | undefined; + private filesHeaderNode: HTMLElement | undefined; + private filesCountBadge: HTMLElement | undefined; + private contentContainer: HTMLElement | undefined; + private overviewContainer: HTMLElement | undefined; + private summaryContainer: HTMLElement | undefined; + private listContainer: HTMLElement | undefined; + // Actions container is positioned outside the card for this layout experiment + private actionsContainer: HTMLElement | undefined; + + private changesProgressBar!: ProgressBar; + private tree: WorkbenchCompressibleObjectTree | undefined; + private ciStatusWidget: CIStatusWidget | undefined; + private splitView: SplitView | undefined; + private splitViewContainer: HTMLElement | undefined; + + private readonly isMergeBaseBranchProtectedContextKey: IContextKey; + private readonly isolationModeContextKey: IContextKey; + private readonly hasGitRepositoryContextKey: IContextKey; + private readonly hasIncomingChangesContextKey: IContextKey; + private readonly hasOpenPullRequestContextKey: IContextKey; + private readonly hasOutgoingChangesContextKey: IContextKey; + private readonly hasPullRequestContextKey: IContextKey; + private readonly hasUncommittedChangesContextKey: IContextKey; + + private readonly renderDisposables = this._register(new DisposableStore()); + + // Track current body dimensions for list layout + private currentBodyHeight = 0; + private currentBodyWidth = 0; + + readonly viewModel: ChangesViewModel; + + 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, + @IEditorService private readonly editorService: IEditorService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @ILabelService private readonly labelService: ILabelService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super({ ...options, titleMenuId: MenuId.ChatEditingSessionTitleToolbar }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + this.viewModel = this.instantiationService.createInstance(ChangesViewModel); + this._register(this.viewModel); + + // Context keys + this.isMergeBaseBranchProtectedContextKey = ActiveSessionContextKeys.IsMergeBaseBranchProtected.bindTo(this.scopedContextKeyService); + this.isolationModeContextKey = ActiveSessionContextKeys.IsolationMode.bindTo(this.scopedContextKeyService); + this.hasGitRepositoryContextKey = ActiveSessionContextKeys.HasGitRepository.bindTo(this.scopedContextKeyService); + this.hasIncomingChangesContextKey = ActiveSessionContextKeys.HasIncomingChanges.bindTo(this.scopedContextKeyService); + this.hasOutgoingChangesContextKey = ActiveSessionContextKeys.HasOutgoingChanges.bindTo(this.scopedContextKeyService); + this.hasUncommittedChangesContextKey = ActiveSessionContextKeys.HasUncommittedChanges.bindTo(this.scopedContextKeyService); + this.hasPullRequestContextKey = ActiveSessionContextKeys.HasPullRequest.bindTo(this.scopedContextKeyService); + this.hasOpenPullRequestContextKey = ActiveSessionContextKeys.HasOpenPullRequest.bindTo(this.scopedContextKeyService); + + // Version mode + this._register(bindContextKey(ChangesContextKeys.VersionMode, this.scopedContextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); + })); + + // View mode + this._register(bindContextKey(ChangesContextKeys.ViewMode, this.scopedContextKeyService, reader => { + return this.viewModel.viewModeObs.read(reader); + })); + + // 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 ?? ''; + })); + + // Title actions + this._register(autorun(reader => { + this.viewModel.activeSessionResourceObs.read(reader); + this.updateActions(); + })); + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyContainer = dom.append(container, $('.changes-view-body')); + + // Actions container - positioned outside and above the card + this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card')); + + // SplitView container for resizable file tree / CI checks split + this.splitViewContainer = dom.append(this.bodyContainer, $('.changes-splitview-container')); + + // Main container with file icons support (the "card") — top pane + this.contentContainer = dom.append(this.splitViewContainer, $('.chat-editing-session-container.show-file-icons')); + this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService)); + + // Toggle class based on whether the file icon theme has file icons + const updateHasFileIcons = () => { + this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons); + }; + updateHasFileIcons(); + this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); + + // Files header + this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header')); + + const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action) => { + if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) { + return this.instantiationService.createInstance(ChangesPickerActionItem, action, this.viewModel); + } + return undefined; + }, + })); + + this.filesCountBadge = dom.append(this.filesHeaderNode, $('.changes-files-count')); + this.filesCountBadge.style.display = 'none'; + + // Overview section (header with summary only - actions moved outside card) + this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview')); + this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary')); + + // Changes card progress bar + const progressContainer = dom.append(this.contentContainer, $('.changes-progress')); + this.changesProgressBar = this._register(new ProgressBar(progressContainer, defaultProgressBarStyles)); + this.changesProgressBar.stop().hide(); + + // List container + this.listContainer = dom.append(this.contentContainer, $('.changes-file-list')); + + // Welcome message for empty state (hidden by default, shown when no changes) + this.welcomeContainer = dom.append(this.contentContainer, $('.changes-welcome')); + this.welcomeContainer.style.display = 'none'; + + const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); + welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); + + // CI Status widget — bottom pane + this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.splitViewContainer)); + + // Create SplitView + this.splitView = this._register(new SplitView(this.splitViewContainer, { + orientation: Orientation.VERTICAL, + proportionalLayout: false, + })); + + // Shared constants for pane sizing + const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT; + const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT; + + // Top pane: file tree + const treePane: IView = { + element: this.contentContainer, + minimumSize: treeMinHeight, + maximumSize: Number.POSITIVE_INFINITY, + onDidChange: Event.None, + layout: (height) => { + this.contentContainer!.style.height = `${height}px`; + this._layoutTreeInPane(height); + }, + }; + + // Bottom pane: CI checks + const ciElement = this.ciStatusWidget.element; + const ciWidget = this.ciStatusWidget; + const ciPane: IView = { + element: ciElement, + get minimumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : ciMinHeight; }, + get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, + onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined), + layout: (height) => { + ciElement.style.height = `${height}px`; + const bodyHeight = Math.max(0, height - CIStatusWidget.HEADER_HEIGHT); + ciWidget.layout(bodyHeight); + }, + }; + + this.splitView.addView(treePane, Sizing.Distribute, 0, true); + this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 1, true); + + // Style the sash as a visible separator between sections + const updateSplitViewStyles = () => { + const borderColor = this.themeService.getColorTheme().getColor(PANEL_SECTION_BORDER); + this.splitView!.style({ separatorBorder: borderColor ?? Color.transparent }); + }; + updateSplitViewStyles(); + this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles)); + + // Initially hide CI pane until checks arrive + this.splitView.setViewVisible(1, false); + + let savedCIPaneHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT; + this._register(this.ciStatusWidget.onDidToggleCollapsed(collapsed => { + if (!this.splitView || !this.ciStatusWidget) { + return; + } + if (collapsed) { + // Save current size before collapsing + const currentSize = this.splitView.getViewSize(1); + if (currentSize > CIStatusWidget.HEADER_HEIGHT) { + savedCIPaneHeight = currentSize; + } + this.splitView.resizeView(1, CIStatusWidget.HEADER_HEIGHT); + } else { + // Restore saved size on expand + this.splitView.resizeView(1, savedCIPaneHeight); + } + this.layoutSplitView(); + })); + + this._register(this.ciStatusWidget.onDidChangeHeight(() => { + if (!this.splitView || !this.ciStatusWidget) { + return; + } + const visible = this.ciStatusWidget.visible; + const isCurrentlyVisible = this.splitView.isViewVisible(1); + if (visible !== isCurrentlyVisible) { + this.splitView.setViewVisible(1, visible); + } + this.layoutSplitView(); + })); + + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { + this.onVisible(); + } else { + this.renderDisposables.clear(); + } + })); + + // Trigger initial render if already visible + if (this.isBodyVisible()) { + this.onVisible(); + } + } + + override getActionsContext(): URI | undefined { + return this.viewModel.activeSessionResourceObs.get(); + } + + private onVisible(): void { + this.renderDisposables.clear(); + + this.renderDisposables.add(autorun(reader => { + const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader); + if (isLoading) { + this.changesProgressBar.infinite().show(200); + } else { + this.changesProgressBar.stop().hide(); + } + })); + + // Changes + const changesObs = derived(reader => { + const changes = this.viewModel.activeSessionChangesObs.read(reader); + return toIChangesFileItem(changes); + }); + + // Changes statistics + const topLevelStats = derived(reader => { + const entries = changesObs.read(reader); + + let added = 0, removed = 0; + + for (const entry of entries) { + added += entry.linesAdded; + removed += entry.linesRemoved; + } + + return { files: entries.length, added, removed }; + }); + + // Setup context keys and actions toolbar + if (this.actionsContainer) { + dom.clearNode(this.actionsContainer); + + // Bind context keys + this._bindContextKeys(topLevelStats); + + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); + + this.renderDisposables.add(scopedInstantiationService.createInstance( + ChangesButtonBarWidget, this.actionsContainer, this.viewModel)); + } + + // Update visibility and file count badge based on entries + this.renderDisposables.add(autorun(reader => { + if (this.viewModel.activeSessionIsLoadingObs.read(reader)) { + return; + } + + const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); + dom.setVisibility(hasGitRepository, this.filesHeaderNode!); + + const { files } = topLevelStats.read(reader); + const hasEntries = files > 0; + + dom.setVisibility(hasEntries, this.listContainer!); + dom.setVisibility(!hasEntries, this.welcomeContainer!); + + if (this.filesCountBadge) { + this.filesCountBadge.textContent = `${files}`; + this.filesCountBadge.style.display = ''; + } + })); + + // Update summary text (line counts only, file count is shown in badge) + if (this.summaryContainer) { + dom.clearNode(this.summaryContainer); + + const linesAddedSpan = dom.$('.working-set-lines-added'); + const linesRemovedSpan = dom.$('.working-set-lines-removed'); + + this.summaryContainer.appendChild(linesAddedSpan); + this.summaryContainer.appendChild(linesRemovedSpan); + + this.renderDisposables.add(autorun(reader => { + if (this.viewModel.activeSessionIsLoadingObs.read(reader)) { + return; + } + + const { added, removed } = topLevelStats.read(reader); + + linesAddedSpan.textContent = `+${added}`; + linesRemovedSpan.textContent = `-${removed}`; + })); + } + + // Create the tree + if (!this.tree && this.listContainer) { + this.tree = this.createChangesTree(this.listContainer, this.onDidChangeBodyVisibility, this._store); + } + + // Register tree event handlers + if (this.tree) { + const tree = this.tree; + + // Re-layout when collapse state changes so the card height adjusts + this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView())); + + const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => { + const { uri: modifiedFileUri, originalUri, isDeletion } = item; + const currentIndex = items.indexOf(item); + + const sidebar = includeSidebar ? { + render: (container: unknown, onDidLayout: Event<{ readonly height: number; readonly width: number }>) => { + return this.renderSidebarList(container as HTMLElement, onDidLayout, items, openFileItem); + } + } : undefined; + + const navigation = { + total: items.length, + current: currentIndex, + navigate: (index: number) => { + const target = items[index]; + if (target) { + openFileItem(target, items, false, false, false, includeSidebar); + } + } + }; + + const group = sideBySide ? SIDE_GROUP : ACTIVE_GROUP; + + if (isDeletion && originalUri) { + this.editorService.openEditor({ + resource: originalUri, + options: { preserveFocus, pinned, modal: { sidebar, navigation } } + }, group); + return; + } + + if (originalUri) { + this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: { preserveFocus, pinned, modal: { sidebar, navigation } } + }, group); + return; + } + + this.editorService.openEditor({ + resource: modifiedFileUri, + options: { preserveFocus, pinned, modal: { sidebar, navigation } } + }, group); + }; + + this.renderDisposables.add(tree.onDidOpen((e) => { + if (!e.element || !isChangesFileItem(e.element)) { + return; + } + + logChangesViewFileSelect(this.telemetryService, e.element.changeType); + + const items = changesObs.get(); + openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, items.length > 1); + })); + } + + // Checks + if (this.ciStatusWidget) { + const checksViewModel = this.instantiationService.createInstance(ChecksViewModel); + this.renderDisposables.add(checksViewModel); + + this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel)); + } + + // Update tree data with combined entries + this.renderDisposables.add(autorun(reader => { + const changes = changesObs.read(reader); + const viewMode = this.viewModel.viewModeObs.read(reader); + const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader); + + if (!this.tree || isLoading) { + return; + } + + // Toggle list-mode class to remove tree indentation in list mode + this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List); + + if (viewMode === ChangesViewMode.Tree) { + // Tree mode: build hierarchical tree from file entries + const treeRootInfo = this.getTreeRootInfo(changes); + const treeChildren = buildTreeChildren(changes, treeRootInfo); + this.tree.setChildren(null, treeChildren); + } else { + // List mode: flat list of file items + const listChildren = changes.map(item => ({ + element: item, + collapsible: false, + } satisfies IObjectTreeElement)); + this.tree.setChildren(null, listChildren); + } + + this.layoutSplitView(); + })); + } + + private _bindContextKeys(topLevelStats: IObservable<{ files: number }>): void { + // Request in progress (can be updated independently since it only affects action enablement, and not visibility) + this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { + const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); + return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; + })); + + // Has changes (can be updated independently since it only affects action enablement, and not visibility) + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { + const { files } = topLevelStats.read(reader); + return files > 0; + })); + + // Bulk update the context keys + this.renderDisposables.add(autorun(reader => { + const state = this.viewModel.activeSessionStateObs.read(reader); + if (!state) { + return; + } + + this.scopedContextKeyService.bufferChangeEvents(() => { + this.isolationModeContextKey.set(state.isolationMode); + this.hasGitRepositoryContextKey.set(state.hasGitRepository); + this.isMergeBaseBranchProtectedContextKey.set(state.isMergeBaseBranchProtected === true); + this.hasPullRequestContextKey.set(state.hasPullRequest === true); + this.hasOpenPullRequestContextKey.set(state.hasOpenPullRequest === true); + this.hasIncomingChangesContextKey.set(state.incomingChanges !== undefined && state.incomingChanges > 0); + this.hasOutgoingChangesContextKey.set(state.outgoingChanges !== undefined && state.outgoingChanges > 0); + this.hasUncommittedChangesContextKey.set(state.uncommittedChanges !== undefined && state.uncommittedChanges > 0); + }); + })); + } + + /** Layout the tree within its SplitView pane. */ + private _layoutTreeInPane(paneHeight: number): void { + if (!this.tree) { + return; + } + // Subtract overview/padding within the content container + const overviewHeight = this.overviewContainer?.offsetHeight ?? 0; + const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; + const treeHeight = Math.max(0, paneHeight - filesHeaderHeight - overviewHeight); + this.tree.layout(treeHeight, this.currentBodyWidth); + this.tree.getHTMLElement().style.height = `${treeHeight}px`; + } + + /** Layout the SplitView to fill available body space. */ + private layoutSplitView(): void { + if (!this.splitView || !this.splitViewContainer) { + return; + } + const bodyHeight = this.currentBodyHeight; + if (bodyHeight <= 0) { + return; + } + const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body + const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; + const actionsMargin = actionsHeight > 0 ? 8 : 0; + const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin); + this.splitViewContainer.style.height = `${availableHeight}px`; + this.splitView.layout(availableHeight); + } + + private getTreeSelection(): IChangesFileItem[] { + const selection = this.tree?.getSelection() ?? []; + return selection.filter(item => !!item && isChangesFileItem(item)); + } + + private getTreeRootInfo(items: readonly IChangesFileItem[]): IChangesTreeRootInfo | undefined { + if (items.length === 0) { + return undefined; + } + + // Get the repository details for the session + // - uri: location of the repository + // - workingDirectory (optional): location of the worktree + const activeSession = this.sessionManagementService.activeSession.get(); + const repository = activeSession?.workspace.get()?.repositories[0]; + const workspaceFolderUri = repository?.workingDirectory ?? repository?.uri; + if (!repository?.uri || !workspaceFolderUri) { + return undefined; + } + + const sampleUri = items[0].uri; + let resourceTreeRootUri = workspaceFolderUri; + + if (sampleUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = sampleUri.path.split('/').filter(Boolean); + if (parts.length >= 3) { + resourceTreeRootUri = sampleUri.with({ path: '/' + parts.slice(0, 3).join('/'), query: '', fragment: '' }); + } + } else if (sampleUri.scheme !== workspaceFolderUri.scheme || sampleUri.authority !== workspaceFolderUri.authority) { + resourceTreeRootUri = sampleUri.with({ path: workspaceFolderUri.path, authority: workspaceFolderUri.authority, query: '', fragment: '' }); + } + + const branchName = this.viewModel.activeSessionStateObs.get()?.branchName; + + return { + root: { + type: 'root', + uri: workspaceFolderUri, + name: repository.workingDirectory + ? `${basename(repository.uri.fsPath)} (${branchName})` + : basename(repository.uri.fsPath), + }, + resourceTreeRootUri, + }; + } + + private getSessionDiscardRef(): string { + const versionMode = this.viewModel.versionModeObs.get(); + const firstCheckpointRef = this.viewModel.activeSessionFirstCheckpointRefObs.get(); + const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.get(); + + return versionMode === ChangesVersionMode.LastTurn + ? lastCheckpointRef + ? `${lastCheckpointRef}^` + : '' + : firstCheckpointRef ?? ''; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.currentBodyHeight = height; + this.currentBodyWidth = width; + this.layoutSplitView(); + } + + override focus(): void { + super.focus(); + this.tree?.domFocus(); + } + + private renderSidebarList( + container: HTMLElement, + onDidLayout: Event<{ readonly height: number; readonly width: number }>, + items: IChangesFileItem[], + openFileItem: (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => void, + ): IDisposable { + const disposables = new DisposableStore(); + + container.classList.add('changes-file-list'); + + const viewMode = this.viewModel.viewModeObs.get(); + container.classList.toggle('list-mode', viewMode === ChangesViewMode.List); + + const tree = this.createChangesTree(container, Event.None, disposables, () => tree.getSelection().filter(item => !!item && isChangesFileItem(item))); + + if (viewMode === ChangesViewMode.Tree) { + tree.setChildren(null, buildTreeChildren(items, this.getTreeRootInfo(items))); + } else { + tree.setChildren(null, items.map(item => ({ element: item as ChangesTreeElement, collapsible: false }))); + } + + // Open file on selection. The `updatingSelection` guard relies on + // `tree.setFocus`/`setSelection` firing events synchronously. + let updatingSelection = false; + disposables.add(tree.onDidOpen(e => { + if (e.element && isChangesFileItem(e.element) && !updatingSelection) { + openFileItem(e.element, items, e.sideBySide, !!e.editorOptions.preserveFocus, !!e.editorOptions.pinned, false /* preserve existing sidebar */); + } + })); + + // Track active editor and highlight in sidebar + disposables.add(Event.runAndSubscribe(this.editorService.onDidActiveEditorChange, () => { + const activeEditor = this.editorService.activeEditor; + if (!activeEditor) { + return; + } + + const primaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); + const secondaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.SECONDARY }); + + const index = items.findIndex(i => + (primaryResource !== undefined && isEqual(i.uri, primaryResource)) || + (secondaryResource !== undefined && i.originalUri !== undefined && isEqual(i.originalUri, secondaryResource)) + ); + if (index >= 0) { + updatingSelection = true; + try { + tree.setFocus([items[index]]); + tree.setSelection([items[index]]); + tree.reveal(items[index]); + } finally { + updatingSelection = false; + } + } + })); + + // Layout on resize + disposables.add(onDidLayout(e => tree.layout(e.height, e.width))); + + return disposables; + } + + private createChangesTree( + container: HTMLElement, + onDidChangeVisibility: Event, + disposables: DisposableStore, + getSelection?: () => IChangesFileItem[], + ): WorkbenchCompressibleObjectTree { + const resourceLabels = disposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility })); + const actionRunner = disposables.add(new ChangesViewActionRunner( + () => this.viewModel.activeSessionResourceObs.get(), + () => this.getSessionDiscardRef(), + getSelection ?? (() => this.getTreeSelection()), + )); + return disposables.add(this.instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'ChangesViewTree', + container, + new ChangesTreeDelegate(), + [this.instantiationService.createInstance(ChangesTreeRenderer, this.viewModel, resourceLabels, actionRunner, + () => { + // Pass in the tree root to be used to compute the label description + const activeSession = this.sessionManagementService.activeSession.get(); + const repository = activeSession?.workspace.get()?.repositories[0]; + return repository?.workingDirectory ?? repository?.uri; + })], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, + getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") + }, + dnd: { + getDragURI: (element: ChangesTreeElement) => element.uri.toString(), + getDragLabel: (elements) => { + const uris = elements.map(e => e.uri); + if (uris.length === 1) { + return this.labelService.getUriLabel(uris[0], { relative: true }); + } + return `${uris.length}`; + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + onDragStart: (data, originalEvent) => { + try { + const elements = data.getData() as ChangesTreeElement[]; + const uris = elements.filter(isChangesFileItem).map(e => e.uri); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); + } catch { + // noop + } + }, + }, + identityProvider: { + getId: (element: ChangesTreeElement) => element.uri.toString() + }, + indent: this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 0 : 8, + compressionEnabled: true, + twistieAdditionalCssClass: (e: unknown) => { + return this.viewModel.viewModeObs.get() === ChangesViewMode.List + ? 'force-no-twistie' + : undefined; + }, + } + )); + } + + override dispose(): void { + this.tree = undefined; + super.dispose(); + } +} + +export class ChangesViewPaneContainer extends ViewPaneContainer { + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IConfigurationService configurationService: IConfigurationService, + @IExtensionService extensionService: IExtensionService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ILogService logService: ILogService, + ) { + super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); + } + + override create(parent: HTMLElement): void { + super.create(parent); + parent.classList.add('changes-viewlet'); + } +} + +// --- Action Runner + +class ChangesViewActionRunner extends ActionRunner { + + constructor( + private readonly getSessionResource: () => URI | undefined, + private readonly getSessionDiscardRef: () => string, + private readonly getSelectedFileItems: () => IChangesFileItem[] + ) { + super(); + } + + protected override async runAction(action: IAction, context: ChangesTreeElement): Promise { + if (!(action instanceof MenuItemAction)) { + return super.runAction(action, context); + } + + const sessionResource = this.getSessionResource(); + const discardRef = this.getSessionDiscardRef(); + const selection = this.getSelectedFileItems(); + + const contextIsSelected = selection.some(s => s === context); + const actualContext = contextIsSelected ? selection : [context]; + const args = actualContext.map(e => { + if (ResourceTree.isResourceNode(e)) { + return ResourceTree.collect(e); + } + + return isChangesFileItem(e) ? [e] : []; + }).flat(); + await action.run(sessionResource, discardRef, ...args.map(item => item.uri)); + } +} + +// --- Tree Delegate + +class ChangesTreeDelegate implements IListVirtualDelegate { + static readonly ROW_HEIGHT = 22; + + getHeight(_element: ChangesTreeElement): number { + return ChangesTreeDelegate.ROW_HEIGHT; + } + + getTemplateId(_element: ChangesTreeElement): string { + return ChangesTreeRenderer.TEMPLATE_ID; + } +} + +// --- View Mode Actions + +class SetChangesListViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setListViewMode', + title: localize('setListViewMode', "View as List"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listTree, + toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.List), + menu: { + id: MenuId.ChatEditingSessionTitleToolbar, + group: '1_viewmode', + order: 1 + } + }); + } + + async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise { + logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.List); + view.viewModel.setViewMode(ChangesViewMode.List); + } +} + +class SetChangesTreeViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setTreeViewMode', + title: localize('setTreeViewMode', "View as Tree"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listFlat, + toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.Tree), + menu: { + id: MenuId.ChatEditingSessionTitleToolbar, + group: '1_viewmode', + order: 2 + } + }); + } + + async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise { + logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.Tree); + view.viewModel.setViewMode(ChangesViewMode.Tree); + } +} + +registerAction2(SetChangesListViewModeAction); +registerAction2(SetChangesTreeViewModeAction); + +// --- Versions Picker Action + +class VersionsPickerAction extends Action2 { + static readonly ID = 'chatEditing.versionsPicker'; + + constructor() { + super({ + id: VersionsPickerAction.ID, + title: localize2('chatEditing.versionsPicker', 'Versions'), + category: CHAT_CATEGORY, + icon: Codicon.listFilter, + f1: false, + menu: [{ + id: MenuId.ChatEditingSessionChangesFileHeaderToolbar, + group: 'navigation', + order: 9, + }], + }); + } + + override async run(): Promise { } +} +registerAction2(VersionsPickerAction); + +class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { + constructor( + action: MenuItemAction, + private readonly viewModel: ChangesViewModel, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const state = viewModel.activeSessionStateObs.get(); + const branchName = state?.branchName; + const baseBranchName = state?.baseBranchName; + + return [ + { + ...action, + id: 'chatEditing.versionsBranchChanges', + label: localize('chatEditing.versionsBranchChanges', 'Branch Changes'), + description: branchName && baseBranchName + ? `${branchName} → ${baseBranchName}` + : branchName, + checked: viewModel.versionModeObs.get() === ChangesVersionMode.BranchChanges, + category: { label: 'changes', order: 1, showHeader: false }, + run: async () => { + viewModel.setVersionMode(ChangesVersionMode.BranchChanges); + logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.BranchChanges); + if (this.element) { + this.renderLabel(this.element); + } + }, + }, + { + ...action, + id: 'chatEditing.versionsAllChanges', + label: localize('chatEditing.versionsAllChanges', 'All Changes'), + description: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'), + checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges, + category: { label: 'checkpoints', order: 2, showHeader: false }, + enabled: viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined && + viewModel.activeSessionLastCheckpointRefObs.get() !== undefined, + run: async () => { + viewModel.setVersionMode(ChangesVersionMode.AllChanges); + logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.AllChanges); + if (this.element) { + this.renderLabel(this.element); + } + }, + }, + { + ...action, + id: 'chatEditing.versionsLastTurnChanges', + label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), + description: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'), + checked: viewModel.versionModeObs.get() === ChangesVersionMode.LastTurn, + category: { label: 'checkpoints', order: 3, showHeader: false }, + enabled: viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined && + viewModel.activeSessionLastCheckpointRefObs.get() !== undefined, + run: async () => { + viewModel.setVersionMode(ChangesVersionMode.LastTurn); + logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.LastTurn); + if (this.element) { + this.renderLabel(this.element); + } + }, + }, + ]; + }, + }; + + super(action, { actionProvider, listOptions: { descriptionBelow: true } }, actionWidgetService, keybindingService, contextKeyService, telemetryService); + + this._register(autorun(reader => { + viewModel.versionModeObs.read(reader); + + if (this.element) { + this.renderLabel(this.element); + } + })); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + const mode = this.viewModel.versionModeObs.get(); + const label = mode === ChangesVersionMode.BranchChanges + ? localize('sessionsChanges.versionsBranchChanges', "Branch Changes") + : mode === ChangesVersionMode.AllChanges + ? localize('sessionsChanges.versionsAllChanges', "All Changes") + : localize('sessionsChanges.versionsLastTurn', "Last Turn's Changes"); + + dom.reset(element, dom.$('span', undefined, label), ...renderLabelWithIcons('$(chevron-down)')); + this.updateAriaLabel(); + return null; + } +} 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..80c50bc74943d --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.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 { 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 { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { ActiveSessionContextKeys, CHANGES_VIEW_ID } 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(ActiveSessionContextKeys.HasChanges, 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/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts new file mode 100644 index 0000000000000..b32fa79351085 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -0,0 +1,448 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, runOnChange, observableValue, observableSignalFromEvent, constObservable, ObservablePromise, derivedObservableWithCache } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { GitDiffChange, GitRepositoryState, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; +import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ChangesVersionMode, ChangesViewMode, IsolationMode } from '../common/changes.js'; + +function toIChatSessionFileChange2(changes: GitDiffChange[], modifiedRef: string | undefined, originalRef: string | undefined): IChatSessionFileChange2[] { + return changes.map(change => ({ + uri: change.uri, + originalUri: change.originalUri + ? originalRef + ? change.originalUri.with({ scheme: 'git', query: JSON.stringify({ path: change.originalUri.fsPath, ref: originalRef }) }) + : change.originalUri + : undefined, + modifiedUri: change.modifiedUri + ? modifiedRef + ? change.modifiedUri.with({ scheme: 'git', query: JSON.stringify({ path: change.modifiedUri.fsPath, ref: modifiedRef }) }) + : change.modifiedUri + : undefined, + insertions: change.insertions, + deletions: change.deletions, + } satisfies IChatSessionFileChange2)); +} + +export interface ActiveSessionState { + readonly isolationMode: IsolationMode; + readonly hasGitRepository: boolean; + readonly branchName: string | undefined; + readonly baseBranchName: string | undefined; + readonly isMergeBaseBranchProtected: boolean | undefined; + readonly incomingChanges: number | undefined; + readonly outgoingChanges: number | undefined; + readonly uncommittedChanges: number | undefined; + readonly hasPullRequest: boolean | undefined; + readonly hasOpenPullRequest: boolean | undefined; +} + +export class ChangesViewModel extends Disposable { + readonly activeSessionResourceObs: IObservable; + readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionRepositoryStateObs: IObservableWithChange; + readonly activeSessionChangesObs: IObservable; + readonly activeSessionHasGitRepositoryObs: IObservable; + readonly activeSessionFirstCheckpointRefObs: IObservable; + readonly activeSessionLastCheckpointRefObs: IObservable; + readonly activeSessionReviewCommentCountByFileObs: IObservable>; + readonly activeSessionAgentFeedbackCountByFileObs: IObservable>; + readonly activeSessionStateObs: IObservable; + readonly activeSessionIsLoadingObs: IObservable; + + private _activeSessionMetadataObs!: IObservable<{ readonly [key: string]: unknown } | undefined>; + private _activeSessionAllChangesPromiseObs!: IObservableWithChange>; + private _activeSessionLastTurnChangesPromiseObs!: IObservableWithChange>; + + 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( + @IAgentFeedbackService private readonly agentFeedbackService: IAgentFeedbackService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitService private readonly gitService: IGitService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Active session resource + this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + // Active session metadata + this._activeSessionMetadataObs = this._getActiveSessionMetadata(); + + // Active session has git repository + this.activeSessionHasGitRepositoryObs = derived(reader => { + const metadata = this._activeSessionMetadataObs.read(reader); + return metadata?.repositoryPath !== undefined; + }); + + // Active session first checkpoint ref + this.activeSessionFirstCheckpointRefObs = derived(reader => { + const metadata = this._activeSessionMetadataObs.read(reader); + return metadata?.firstCheckpointRef as string | undefined; + }); + + // Active session last checkpoint ref + this.activeSessionLastCheckpointRefObs = derived(reader => { + const metadata = this._activeSessionMetadataObs.read(reader); + return metadata?.lastCheckpointRef as string | undefined; + }); + + // Active session repository + const { repository, repositoryState } = this._getActiveSessionGitRepository(); + this.activeSessionRepositoryObs = repository; + this.activeSessionRepositoryStateObs = repositoryState; + + // Active session state + const { isLoading, state } = this._getActiveSessionState(); + this.activeSessionIsLoadingObs = isLoading; + this.activeSessionStateObs = state; + + // Active session changes + this.activeSessionChangesObs = this._getActiveSessionChanges(); + + // Active session review comment count by file + this.activeSessionReviewCommentCountByFileObs = this._getActiveSessionReviewComments(); + + // Active session agent feedback count by file + this.activeSessionAgentFeedbackCountByFileObs = this._getActiveSessionAgentFeedback(); + + // Version mode + this.versionModeObs = observableValue(this, ChangesVersionMode.BranchChanges); + + this._register(runOnChange(this.activeSessionResourceObs, () => { + this.setVersionMode(ChangesVersionMode.BranchChanges); + })); + + // 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); + } + + private _getActiveSessionMetadata(): IObservable<{ readonly [key: string]: unknown } | undefined> { + const sessionsChangedSignal = observableSignalFromEvent(this, + this.sessionManagementService.onDidChangeSessions); + + return derivedOpts<{ readonly [key: string]: unknown } | undefined>({ + equalsFn: structuralEquals + }, reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return undefined; + } + + sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + return model?.metadata; + }); + } + + private _getActiveSessionGitRepository(): { repository: IObservable; repositoryState: IObservable } { + const activeSessionRepositoryObs = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.workspace.read(reader)?.repositories[0]; + }); + + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return constObservable(undefined); + } + + const activeSessionRepository = activeSessionRepositoryObs.read(reader); + const workingDirectory = activeSessionRepository?.workingDirectory ?? activeSessionRepository?.uri; + if (!workingDirectory) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(workingDirectory)).resolvedValue; + }); + + const activeSessionGitRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + const activeSessionGitRepositoryStateObs = derived(reader => { + const repository = activeSessionGitRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + + // If the repository has no HEAD, it is likely not fully loaded yet. + // Treat it as undefined to avoid showing incorrect information to + // the user. + if (!repositoryState?.HEAD) { + return undefined; + } + + return repositoryState; + }); + + return { + repository: activeSessionGitRepositoryObs, + repositoryState: activeSessionGitRepositoryStateObs + }; + } + + private _getActiveSessionChanges(): IObservable { + // Changes + const activeSessionChangesObs = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + if (!activeSession) { + return Iterable.empty(); + } + return activeSession.changes.read(reader); + }); + + // All changes + this._activeSessionAllChangesPromiseObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + const firstCheckpointRef = this.activeSessionFirstCheckpointRefObs.read(reader); + const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader); + + if (!repository || !firstCheckpointRef || !lastCheckpointRef) { + return constObservable([]); + } + + const diffPromise = repository.diffBetweenWithStats(firstCheckpointRef, lastCheckpointRef); + return new ObservablePromise(diffPromise).resolvedValue; + }); + + const activeSessionAllChangesObs = derived(reader => { + const diffChanges = this._activeSessionAllChangesPromiseObs.read(reader).read(reader) ?? []; + const firstCheckpointRef = this.activeSessionFirstCheckpointRefObs.read(undefined); + const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(undefined); + + return toIChatSessionFileChange2(diffChanges, lastCheckpointRef, firstCheckpointRef); + }); + + // Last turn changes + this._activeSessionLastTurnChangesPromiseObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader); + + if (!repository || !lastCheckpointRef) { + return constObservable([]); + } + + const diffPromise = repository.diffBetweenWithStats(`${lastCheckpointRef}^`, lastCheckpointRef); + return new ObservablePromise(diffPromise).resolvedValue; + }); + + const activeSessionLastTurnChangesObs = derived(reader => { + const diffChanges = this._activeSessionLastTurnChangesPromiseObs.read(reader).read(reader) ?? []; + const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(undefined); + + return toIChatSessionFileChange2(diffChanges, lastCheckpointRef, lastCheckpointRef ? `${lastCheckpointRef}^` : undefined); + }); + + return derivedOpts({ + equalsFn: arrayEqualsC() + }, reader => { + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); + if (!hasGitRepository) { + return []; + } + + const versionMode = this.versionModeObs.read(reader); + if (versionMode === ChangesVersionMode.BranchChanges) { + return activeSessionChangesObs.read(reader); + } else if (versionMode === ChangesVersionMode.AllChanges) { + return activeSessionAllChangesObs.read(reader); + } else if (versionMode === ChangesVersionMode.LastTurn) { + return activeSessionLastTurnChangesObs.read(reader); + } + + return []; + }); + } + + private _getActiveSessionState(): { isLoading: IObservable; state: IObservable } { + const isLoadingObs = derived(reader => { + // If there is a git repository, wait for the repository to be opened first, + // as there are many context keys that depend on the repository information. + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); + if (hasGitRepository && this.activeSessionRepositoryStateObs.read(reader) === undefined) { + return true; + } + + // Branch changes + const versionMode = this.versionModeObs.read(reader); + if (versionMode === ChangesVersionMode.BranchChanges) { + return false; + } + + // All changes + if (versionMode === ChangesVersionMode.AllChanges) { + const allChangesResult = this._activeSessionAllChangesPromiseObs.read(reader).read(reader); + return allChangesResult === undefined; + } + + // Last turn changes + if (versionMode === ChangesVersionMode.LastTurn) { + const lastTurnChangesResult = this._activeSessionLastTurnChangesPromiseObs.read(reader).read(reader); + return lastTurnChangesResult === undefined; + } + + return false; + }); + + const activeSessionStateObs = derivedObservableWithCache(this, (reader, lastValue) => { + const isLoading = isLoadingObs.read(reader); + const activeSession = this.sessionManagementService.activeSession.read(reader); + const repositoryState = this.activeSessionRepositoryStateObs.read(reader); + if (isLoading && repositoryState === undefined) { + return lastValue; + } + + // Session state + const sessionMetadata = this._activeSessionMetadataObs.read(reader); + const workspace = activeSession?.workspace.read(reader); + const workspaceRepository = workspace?.repositories[0]; + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); + const branchName = sessionMetadata?.branchName as string | undefined; + const baseBranchName = sessionMetadata?.baseBranchName as string | undefined; + const isMergeBaseBranchProtected = workspaceRepository?.baseBranchProtected; + const isolationMode = workspaceRepository?.workingDirectory === undefined + ? IsolationMode.Workspace + : IsolationMode.Worktree; + + // Pull request state + const gitHubInfo = activeSession?.gitHubInfo.read(reader); + const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; + const hasOpenPullRequest = hasPullRequest && + (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || + gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); + + // Repository state + const incomingChanges = hasGitRepository + ? repositoryState?.HEAD?.behind ?? 0 + : undefined; + const outgoingChanges = hasGitRepository + ? repositoryState?.HEAD?.ahead ?? 0 + : undefined; + const uncommittedChanges = hasGitRepository + ? (repositoryState?.mergeChanges.length ?? 0) + + (repositoryState?.indexChanges.length ?? 0) + + (repositoryState?.workingTreeChanges.length ?? 0) + + (repositoryState?.untrackedChanges.length ?? 0) + : undefined; + + return { + isolationMode, + hasGitRepository, + branchName, + baseBranchName, + isMergeBaseBranchProtected, + incomingChanges, + outgoingChanges, + uncommittedChanges, + hasPullRequest, + hasOpenPullRequest + } satisfies ActiveSessionState; + }); + + return { + isLoading: isLoadingObs, + state: derivedOpts({ equalsFn: structuralEquals }, + reader => activeSessionStateObs.read(reader)) + }; + } + + private _getActiveSessionReviewComments(): IObservable> { + return derived(reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + const changes = [...this.activeSessionChangesObs.read(reader)]; + + if (!sessionResource) { + return new Map(); + } + + 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); + } + } + + if (changes.length === 0) { + return result; + } + + const reviewFiles = getCodeReviewFilesFromSessionChanges(changes); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + + if (reviewState.kind !== CodeReviewStateKind.Result || reviewState.version !== reviewVersion) { + return result; + } + + for (const comment of reviewState.comments) { + const uriKey = comment.uri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } + + return result; + }); + } + + private _getActiveSessionAgentFeedback(): IObservable> { + return derived(reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return new Map(); + } + + observableSignalFromEvent(this, this.agentFeedbackService.onDidChangeFeedback).read(reader); + + const feedbackItems = this.agentFeedbackService.getFeedback(sessionResource); + const result = new Map(); + for (const item of feedbackItems) { + if (!item.sourcePRReviewCommentId) { + const uriKey = item.resourceUri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } + } + return result; + }); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts new file mode 100644 index 0000000000000..e3874cb8cd021 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts @@ -0,0 +1,422 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ICompressedTreeElement, ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; +import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { ActionRunner } from '../../../../base/common/actions.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename, dirname, extUriBiasedIgnorePathCase, relativePath } from '../../../../base/common/resources.js'; +import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js'; +import { URI } from '../../../../base/common/uri.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionData.js'; +import { ActiveSessionContextKeys, ChangesContextKeys, ChangesViewMode } from '../common/changes.js'; +import { ChangesViewModel } from './changesViewModel.js'; + +const $ = dom.$; + +export function toIChangesFileItem(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): IChangesFileItem[] { + return changes.map(change => { + const isAddition = change.originalUri === undefined; + const isDeletion = change.modifiedUri === undefined; + const uri = isIChatSessionFileChange2(change) + ? change.uri + : change.modifiedUri; + + return { + type: 'file', + uri, + originalUri: change.originalUri, + isDeletion, + state: ModifiedFileEntryState.Accepted, + changeType: isAddition + ? 'added' + : isDeletion + ? 'deleted' + : 'modified', + linesAdded: change.insertions, + linesRemoved: change.deletions + } satisfies IChangesFileItem; + }); +} + +type ChangeType = 'added' | 'modified' | 'deleted' | 'none'; + +export interface IChangesFileItem { + readonly type: 'file'; + readonly uri: URI; + readonly originalUri?: URI; + readonly state: ModifiedFileEntryState; + readonly isDeletion: boolean; + readonly changeType: ChangeType; + readonly linesAdded: number; + readonly linesRemoved: number; +} + +export interface IChangesRootItem { + readonly type: 'root'; + readonly uri: URI; + readonly name: string; +} + +export interface IChangesTreeRootInfo { + readonly root: IChangesRootItem; + readonly resourceTreeRootUri: URI; +} + +export type ChangesTreeElement = IChangesRootItem | IChangesFileItem | IResourceNode; + +export function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { + return !ResourceTree.isResourceNode(element) && element.type === 'file'; +} + +export function isChangesRootItem(element: ChangesTreeElement): element is IChangesRootItem { + return !ResourceTree.isResourceNode(element) && element.type === 'root'; +} + +export function buildTreeChildren(items: IChangesFileItem[], treeRootInfo?: IChangesTreeRootInfo): ICompressedTreeElement[] { + if (items.length === 0) { + return []; + } + + let rootUri = treeRootInfo?.resourceTreeRootUri ?? URI.file('/'); + + // For github-remote-file URIs, set the root to /{owner}/{repo}/{ref} + // so the tree shows repo-relative paths instead of internal URI segments. + if (!treeRootInfo && items[0].uri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = items[0].uri.path.split('/').filter(Boolean); + if (parts.length >= 3) { + rootUri = items[0].uri.with({ path: '/' + parts.slice(0, 3).join('/') }); + } + } + + const resourceTree = new ResourceTree(undefined, rootUri, extUriBiasedIgnorePathCase); + for (const item of items) { + resourceTree.add(item.uri, item); + } + + function convertChildren(parent: IResourceNode): ICompressedTreeElement[] { + const result: ICompressedTreeElement[] = []; + for (const child of parent.children) { + if (child.element && child.childrenCount === 0) { + // Leaf node — just the file item + result.push({ + element: child.element, + collapsible: false, + incompressible: true, + }); + } else { + // Folder node. Ensure that the first level of folders under + // the root folder are not being collapsed with the root folder + // as that is a special node showing the workspace folder and + // branch information. + result.push({ + element: child, + children: convertChildren(child), + incompressible: parent === resourceTree.root, + collapsible: true, + collapsed: false, + }); + } + } + return result; + } + + const children = convertChildren(resourceTree.root); + if (!treeRootInfo) { + return children; + } + + return [{ + element: treeRootInfo.root, + children, + collapsible: true, + collapsed: false, + incompressible: true, + }]; +} + +interface IChangesTreeTemplate { + readonly label: IResourceLabel; + readonly toolbar: MenuWorkbenchToolBar | undefined; + readonly contextKeyService: IContextKeyService | undefined; + readonly reviewCommentsBadge: HTMLElement; + readonly agentFeedbackBadge: HTMLElement; + readonly decorationBadge: HTMLElement; + readonly addedSpan: HTMLElement; + readonly removedSpan: HTMLElement; + readonly lineCountsContainer: HTMLElement; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; +} + +export class ChangesTreeRenderer implements ICompressibleTreeRenderer { + static TEMPLATE_ID = 'changesTreeRenderer'; + readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID; + + constructor( + private viewModel: ChangesViewModel, + private labels: ResourceLabels, + private actionRunner: ActionRunner | undefined, + private getRootUri: () => URI | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILabelService private readonly labelService: ILabelService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + ) { } + + renderTemplate(container: HTMLElement): IChangesTreeTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const reviewCommentsBadge = dom.$('.changes-review-comments-badge'); + label.element.appendChild(reviewCommentsBadge); + + const agentFeedbackBadge = dom.$('.changes-agent-feedback-badge'); + label.element.appendChild(agentFeedbackBadge); + + const lineCountsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + lineCountsContainer.appendChild(addedSpan); + lineCountsContainer.appendChild(removedSpan); + label.element.appendChild(lineCountsContainer); + + const actionBarContainer = $('.chat-collapsible-list-action-bar'); + const contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + const toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ChatEditingSessionChangeToolbar, { menuOptions: { shouldForwardArgs: true, arg: undefined }, actionRunner: this.actionRunner })); + label.element.appendChild(actionBarContainer); + + templateDisposables.add(bindContextKey(ChatContextKeys.agentSessionType, contextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.sessionType ?? ''; + })); + + templateDisposables.add(bindContextKey(ActiveSessionContextKeys.HasGitRepository, contextKeyService, reader => { + return this.viewModel.activeSessionHasGitRepositoryObs.read(reader); + })); + + templateDisposables.add(bindContextKey(ChangesContextKeys.VersionMode, contextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); + })); + + const decorationBadge = dom.$('.changes-decoration-badge'); + label.element.appendChild(decorationBadge); + + return { label, toolbar, contextKeyService, reviewCommentsBadge, agentFeedbackBadge, decorationBadge, addedSpan, removedSpan, lineCountsContainer, elementDisposables: new DisposableStore(), templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChangesTreeTemplate): void { + const element = node.element; + templateData.label.element.style.display = 'flex'; + + if (isChangesRootItem(element)) { + // Root element + this.renderRootElement(element, templateData); + } else if (ResourceTree.isResourceNode(element)) { + // Folder element + this.renderFolderElement(element, templateData); + } else { + // File element + this.renderFileElement(element, templateData); + } + } + + renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { + const compressed = node.element as ICompressedTreeNode>; + const folder = compressed.elements[compressed.elements.length - 1]; + + templateData.label.element.style.display = 'flex'; + + const label = compressed.elements.map(e => e.name); + templateData.label.setResource({ resource: folder.uri, name: label }, { + fileKind: FileKind.FOLDER, + separator: this.labelService.getSeparator(folder.uri.scheme), + }); + + // Hide file-specific decorations for folders + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.agentFeedbackBadge.style.display = 'none'; + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = folder; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + + private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void { + const root = this.getRootUri(); + const viewMode = this.viewModel.viewModeObs.get(); + + templateData.label.setResource({ + resource: data.uri, + name: basename(data.uri), + description: viewMode === ChangesViewMode.List + ? root + ? relativePath(root, dirname(data.uri)) + : undefined + : undefined, + }, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + strikethrough: data.changeType === 'deleted' + }); + + const showChangeDecorations = data.changeType !== 'none'; + + // Show file-specific decorations for changed files only + templateData.lineCountsContainer.style.display = showChangeDecorations ? '' : 'none'; + templateData.decorationBadge.style.display = showChangeDecorations ? '' : 'none'; + + // Review comments + templateData.elementDisposables.add(autorun(reader => { + const reviewCommentByFile = this.viewModel.activeSessionReviewCommentCountByFileObs.read(reader); + const reviewCommentCount = reviewCommentByFile?.get(data.uri.fsPath) ?? 0; + + if (reviewCommentCount > 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + })); + + // Agent feedback + templateData.elementDisposables.add(autorun(reader => { + const agentFeedbackByFile = this.viewModel.activeSessionAgentFeedbackCountByFileObs.read(reader); + const agentFeedbackCount = agentFeedbackByFile?.get(data.uri.fsPath) ?? 0; + + if (agentFeedbackCount > 0) { + templateData.agentFeedbackBadge.style.display = ''; + templateData.agentFeedbackBadge.className = 'changes-agent-feedback-badge'; + templateData.agentFeedbackBadge.replaceChildren( + dom.$('.codicon.codicon-comment'), + dom.$('span', undefined, `${agentFeedbackCount}`) + ); + } else { + templateData.agentFeedbackBadge.style.display = 'none'; + templateData.agentFeedbackBadge.replaceChildren(); + } + })); + + const badge = templateData.decorationBadge; + badge.className = 'changes-decoration-badge'; + if (showChangeDecorations) { + // Update decoration badge (A/M/D) + switch (data.changeType) { + case 'added': + badge.textContent = 'A'; + badge.classList.add('added'); + break; + case 'deleted': + badge.textContent = 'D'; + badge.classList.add('deleted'); + break; + case 'modified': + default: + badge.textContent = 'M'; + badge.classList.add('modified'); + break; + } + + templateData.addedSpan.textContent = `+${data.linesAdded}`; + templateData.removedSpan.textContent = `-${data.linesRemoved}`; + + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + } else { + badge.textContent = ''; + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified'); + } + + if (templateData.toolbar) { + templateData.toolbar.context = data; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + + private renderRootElement(data: IChangesRootItem, templateData: IChangesTreeTemplate): void { + templateData.label.setResource({ + resource: data.uri, + name: data.name, + }, { + fileKind: FileKind.ROOT_FOLDER, + separator: this.labelService.getSeparator(data.uri.scheme, data.uri.authority), + }); + + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.agentFeedbackBadge.style.display = 'none'; + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = data.uri; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + + private renderFolderElement(node: IResourceNode, templateData: IChangesTreeTemplate): void { + templateData.label.setFile(node.uri, { + fileKind: FileKind.FOLDER, + hidePath: true, + }); + + // Hide file-specific decorations for folders + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.agentFeedbackBadge.style.display = 'none'; + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = node; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + + disposeElement(_element: ITreeNode, _index: number, templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.clear(); + } + + disposeCompressedElements(_element: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IChangesTreeTemplate): void { + templateData.elementDisposables.dispose(); + templateData.templateDisposables.dispose(); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/checksActions.ts b/src/vs/sessions/contrib/changes/browser/checksActions.ts new file mode 100644 index 0000000000000..7bd12cab3c71f --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/checksActions.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 gitHubInfo = session.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; + } + const prModel = gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + return gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.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 gitHubInfo = activeSession.gitHubInfo.get(); + if (!gitHubInfo?.pullRequest) { + return; + } + + const prModel = gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const pr = prModel.pullRequest.get(); + if (!pr) { + return; + } + + const ciModel = gitHubService.getPullRequestCI(gitHubInfo.owner, gitHubInfo.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 sessionResource = activeSession.resource; + 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/checksViewModel.ts b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts new file mode 100644 index 0000000000000..e44422f93b9d9 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/checksViewModel.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; + +export class ChecksViewModel extends Disposable { + readonly activeSessionResourceObs: IObservable; + readonly checksObs: IObservable; + + constructor( + @IGitHubService gitHubService: IGitHubService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + this.activeSessionResourceObs = derived(this, reader => { + const session = sessionManagementService.activeSession.read(reader); + return session?.resource; + }); + + const pullRequestInfoObs = derivedOpts<{ owner: string; repo: string; headRef: string } | undefined>({ + equalsFn: structuralEquals + }, reader => { + const session = sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + + const gitHubInfo = session.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; + } + + const prModel = gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + + return { + owner: gitHubInfo.owner, + repo: gitHubInfo.repo, + headRef: pr.headSha + }; + }); + + this.checksObs = derived(this, reader => { + const pullRequestInfo = pullRequestInfoObs.read(reader); + if (!pullRequestInfo) { + 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 = gitHubService.getPullRequestCI(pullRequestInfo.owner, pullRequestInfo.repo, pullRequestInfo.headRef); + ciModel.refresh(); + ciModel.startPolling(); + reader.store.add({ dispose: () => ciModel.stopPolling() }); + + return ciModel; + }); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/checksWidget.ts b/src/vs/sessions/contrib/changes/browser/checksWidget.ts new file mode 100644 index 0000000000000..5b9b61c2f3156 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/checksWidget.ts @@ -0,0 +1,521 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/checksWidget.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 } 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, IGitHubCICheck } from '../../github/common/types.js'; +import { GitHubPullRequestCIModel, parseWorkflowRunId } from '../../github/browser/models/githubPullRequestCIModel.js'; +import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './checksActions.js'; +import { ChecksViewModel } from './checksViewModel.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 = 28; + + 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, + private readonly _getModel: () => GitHubPullRequestCIModel | undefined, + ) { } + + 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.group === CICheckGroup.Failed && parseWorkflowRunId(element.check.detailsUrl) !== undefined) { + actions.push(templateData.elementDisposables.add(new Action( + 'ci.rerunCheck', + localize('ci.rerunCheck', "Rerun Check"), + ThemeIcon.asClassName(Codicon.debugRerun), + true, + async () => { + await this._getModel()?.rerunFailedCheck(element.check); + }, + ))); + } + + 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 widget that shows the CI status of a PR. + * Rendered beneath the changes tree in the changes view as a SplitView pane. + */ +export class CIStatusWidget extends Disposable { + + static readonly HEADER_HEIGHT = 34; // total header height in px + static readonly MIN_BODY_HEIGHT = 84; // at least 3 checks (3 * 28) + static readonly PREFERRED_BODY_HEIGHT = 112; // preferred 4 checks (4 * 28) + static readonly MAX_BODY_HEIGHT = 240; // at most ~8 checks + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabelNode: HTMLElement; + private readonly _countsNode: HTMLElement; + private readonly _headerActionBarContainer: HTMLElement; + private readonly _headerActionBar: ActionBar; + 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 readonly _onDidToggleCollapsed = this._register(new Emitter()); + readonly onDidToggleCollapsed = this._onDidToggleCollapsed.event; + + private _checkCount = 0; + private _collapsed = false; + private _model: GitHubPullRequestCIModel | undefined; + private _sessionResource: URI | undefined; + private readonly _chevronNode: HTMLElement; + + 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; + } + + /** Whether the body is collapsed (header-only). */ + get collapsed(): boolean { + return this._collapsed; + } + + 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, click to collapse/expand) + this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title')); + this._titleLabelNode = dom.append(this._titleNode, $('.ci-status-widget-title-label')); + this._titleLabelNode.textContent = localize('ci.checksLabel', "Checks"); + this._countsNode = dom.append(this._titleNode, $('.ci-status-widget-counts')); + this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions')); + this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer)); + this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + })); + this._chevronNode = dom.append(this._headerNode, $('.group-chevron')); + this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + + this._headerNode.setAttribute('role', 'button'); + this._headerNode.setAttribute('aria-label', localize('ci.toggleChecks', "Toggle Checks")); + this._headerNode.setAttribute('aria-expanded', 'true'); + this._headerNode.tabIndex = 0; + + this._register(dom.addDisposableListener(this._headerNode, dom.EventType.CLICK, e => { + // Don't toggle when clicking the action bar + if (dom.isAncestor(e.target as HTMLElement, this._headerActionBarContainer)) { + return; + } + this._toggleCollapsed(); + })); + this._register(dom.addDisposableListener(this._headerNode, dom.EventType.KEY_DOWN, e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target === this._headerNode) { + e.preventDefault(); + this._toggleCollapsed(); + } + })); + + // Body (list of checks) + const bodyId = 'ci-status-widget-body'; + this._bodyNode = dom.append(this._domNode, $(`.${bodyId}`)); + this._bodyNode.id = bodyId; + this._headerNode.setAttribute('aria-controls', bodyId); + + const listContainer = $('.ci-status-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList, + 'CIStatusWidget', + listContainer, + new CICheckListDelegate(), + [new CICheckListRenderer(this._labels, this._openerService, () => this._model)], + { + 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()); + } + + setInput(input: ChecksViewModel): IDisposable { + return autorun(reader => { + this._model = input.checksObs.read(reader); + this._sessionResource = input.activeSessionResourceObs.read(reader); + + if (!this._model) { + this._checkCount = 0; + this._setCollapsed(false); + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + this._onDidChangeHeight.fire(); + return; + } + + const checks = this._model.checks.read(reader); + + if (checks.length === 0) { + this._checkCount = 0; + this._setCollapsed(false); + 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); + this._renderHeaderActions(getFailedChecks(checks)); + this._renderBody(sorted); + + if (this._checkCount !== oldCount) { + this._onDidChangeHeight.fire(); + } + }); + } + + private _renderHeader(checks: readonly IGitHubCICheck[]): void { + const counts = getCheckCounts(checks); + + // Update count badges + dom.clearNode(this._countsNode); + + if (counts.running > 0) { + const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-running')); + badge.appendChild(renderIcon(Codicon.circleFilled)); + dom.append(badge, $('span')).textContent = `${counts.running}`; + } + + if (counts.failed > 0) { + const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-failure')); + badge.appendChild(renderIcon(Codicon.error)); + dom.append(badge, $('span')).textContent = `${counts.failed}`; + } + + if (counts.pending > 0) { + const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-pending')); + badge.appendChild(renderIcon(Codicon.circleFilled)); + dom.append(badge, $('span')).textContent = `${counts.pending}`; + } + + if (counts.successful > 0) { + const badge = dom.append(this._countsNode, $('.ci-status-widget-count-badge.ci-status-success')); + badge.appendChild(renderIcon(Codicon.passFilled)); + dom.append(badge, $('span')).textContent = `${counts.successful}`; + } + } + + private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void { + this._headerActionDisposables.clear(); + this._headerActionBar.clear(); + + if (failedChecks.length === 0) { + this._headerActionBarContainer.classList.remove('has-actions'); + this._domNode.classList.remove('has-fix-actions'); + 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.classList.add('has-actions'); + this._domNode.classList.add('has-fix-actions'); + } + + /** + * Layout the widget body list to the given height. + * Called by the parent view after computing available space. + */ + layout(height: number): void { + if (this._collapsed) { + this._bodyNode.style.display = 'none'; + return; + } + this._bodyNode.style.display = ''; + this._list.layout(height); + } + + private _toggleCollapsed(): void { + this._setCollapsed(!this._collapsed); + this._onDidToggleCollapsed.fire(this._collapsed); + // Also fires onDidChangeHeight so the SplitView pane updates its min/max constraints + this._onDidChangeHeight.fire(); + } + + private _setCollapsed(collapsed: boolean): void { + this._collapsed = collapsed; + this._updateChevron(); + this._headerNode.setAttribute('aria-expanded', String(!collapsed)); + } + + private _updateChevron(): void { + this._chevronNode.className = 'group-chevron'; + this._chevronNode.classList.add( + ...ThemeIcon.asClassNameArray( + this._collapsed ? Codicon.chevronRight : Codicon.chevronDown + ) + ); + } + + private _renderBody(checks: readonly ICICheckListItem[]): void { + 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 getCheckIcon(check: IGitHubCICheck): ThemeIcon { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return Codicon.sync; + 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/media/changesTitleBarWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css new file mode 100644 index 0000000000000..e6c74dba2d0e8 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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) ---- */ + +/* Separator between local-session actions (Run, VS Code) and fixed toggles (Terminal, Changes). + * Targets the action following the VS Code icon (any Codicon.vscode variant). */ +.agent-sessions-workbench .titlebar-session-actions-container .monaco-action-bar .actions-container > .action-item:has(.codicon[class*="codicon-vscode"]) + .action-item { + position: relative; + margin-left: 17px; +} + +.agent-sessions-workbench .titlebar-session-actions-container .monaco-action-bar .actions-container > .action-item:has(.codicon[class*="codicon-vscode"]) + .action-item::before { + content: ''; + position: absolute; + left: -9px; + top: 3px; + width: 1px; + height: 16px; + background-color: var(--vscode-disabledForeground); +} + +.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.toggled, +.agent-sessions-workbench .changes-titlebar-indicator.toggled:hover { + 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 52% rename from src/vs/sessions/contrib/changesView/browser/media/changesView.css rename to src/vs/sessions/contrib/changes/browser/media/changesView.css index 1300b886cbcd8..a8ae8364fd444 100644 --- a/src/vs/sessions/contrib/changesView/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -3,16 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.flex-grow { - flex-grow: 1; -} - .changes-view-body { display: flex; flex-direction: column; height: 100%; - padding: 8px; - box-sizing: border-box; + padding: 4px 8px; + box-sizing: border-box; +} + +/* SplitView container */ +.changes-view-body .changes-splitview-container { + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Progress bar */ +.changes-view-body .chat-editing-session-container .changes-progress { + position: relative; } /* Welcome/Empty state */ @@ -21,16 +29,16 @@ flex-direction: column; align-items: center; justify-content: center; - flex: 1; - padding: 20px; - text-align: center; - gap: 8px; + flex: 1; + padding: 32px; + text-align: center; + gap: 8px; } .changes-view-body .changes-welcome-icon.codicon { - font-size: 48px !important; + font-size: 32px !important; color: var(--vscode-descriptionForeground); - opacity: 0.6; + opacity: 0.4; } .changes-view-body .changes-welcome-message { @@ -38,19 +46,59 @@ font-size: 12px; } -/* Main container - matches chat editing session styling */ +/* Main container */ .changes-view-body .chat-editing-session-container { - padding: 4px 3px; box-sizing: border-box; - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 4px; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } +/* Files header */ +.changes-view-body .changes-files-header { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + min-height: 22px; + font-weight: 500; + font-size: 12px; +} + +.changes-view-body .changes-files-header-toolbar { + flex: 1; +} + +.changes-view-body .changes-files-header-toolbar .action-label { + font-size: 12px; + align-items: center; + + > span { + margin-left: 2px; + } + + > .codicon { + font-size: 10px !important; + padding-left: 4px; + width: 10px; + height: 10px; + } +} + +.changes-view-body .changes-files-count { + flex-shrink: 0; + font-size: 11px; + padding: 2px 0; + border-radius: 4px; + background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + color: var(--vscode-descriptionForeground); + line-height: 1; + font-weight: 600; + min-width: 16px; + text-align: center; +} + /* Overview section (header) - hidden since actions moved outside card */ .changes-view-body .chat-editing-session-overview { display: none; @@ -68,29 +116,27 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - border: 1px solid var(--vscode-input-border); - border-radius: 4px; } /* Line counts in header */ .changes-view-body .changes-summary .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); font-size: 11px; - font-weight: 500; + font-weight: 500; } .changes-view-body .changes-summary .working-set-lines-removed { color: var(--vscode-chat-linesRemovedForeground); font-size: 11px; - font-weight: 500; + font-weight: 500; } /* Actions container */ .changes-view-body .chat-editing-session-actions { display: flex; flex-direction: row; - flex-wrap: nowrap; - gap: 6px; + flex-wrap: nowrap; + gap: 4px; align-items: center; } @@ -103,8 +149,7 @@ /* Larger action buttons matching SCM ActionButton style */ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button { height: 26px; - padding: 4px 14px; - border-radius: 4px; + padding: 4px; font-size: 12px; line-height: 18px; } @@ -112,12 +157,51 @@ /* Primary button grows to fill available space */ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button:not(.secondary) { flex: 1; + min-width: 0; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button:not(.secondary) > span:not(.codicon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ButtonWithDropdown container grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown { + flex: 1; + min-width: 0; + display: flex; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button { + flex: 1; + min-width: 0; + box-sizing: border-box; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button > span:not(.codicon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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; + padding: 4px 6px; font-size: 16px !important; } + .changes-view-body .chat-editing-session-actions .monaco-button { width: fit-content; overflow: hidden; @@ -136,29 +220,34 @@ 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); } /* List container */ -.changes-view-body .chat-editing-session-list { +.changes-file-list { overflow: hidden; } /* Make the vertical scrollbar overlay on top of content instead of shifting it */ -.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .scrollbar.vertical { +.changes-file-list .monaco-scrollable-element > .scrollbar.vertical { z-index: 1; } -.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .monaco-list-rows { +.changes-file-list .monaco-scrollable-element > .monaco-list-rows { width: 100% !important; } /* Remove tree indentation padding for hidden twisties (both list and tree mode) */ -.changes-view-body .chat-editing-session-list .monaco-tl-twistie.force-no-twistie { +.changes-file-list .monaco-tl-twistie.force-no-twistie { padding-left: 0 !important; } + /* List rows */ .changes-view-body .chat-editing-session-container:not(.has-file-icons) .monaco-list-row .monaco-icon-label { margin-left: 6px; @@ -169,19 +258,26 @@ } /* Action bar in list rows */ -.changes-view-body .monaco-list-row .chat-collapsible-list-action-bar { +.changes-file-list .monaco-list-row .chat-collapsible-list-action-bar { padding-left: 5px; display: none; } -.changes-view-body .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), -.changes-view-body .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), -.changes-view-body .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { +.changes-file-list .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-file-list .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-file-list .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { display: inherit; } +/* Hide diff stats on hover/focus/select when toolbar has actions */ +.changes-file-list .monaco-list-row:hover .monaco-icon-label:has(.chat-collapsible-list-action-bar:not(.has-no-actions)) .working-set-line-counts, +.changes-file-list .monaco-list-row.focused .monaco-icon-label:has(.chat-collapsible-list-action-bar:not(.has-no-actions)) .working-set-line-counts, +.changes-file-list .monaco-list-row.selected .monaco-icon-label:has(.chat-collapsible-list-action-bar:not(.has-no-actions)) .working-set-line-counts { + display: none; +} + /* Decoration badges (A/M/D) */ -.changes-view-body .chat-editing-session-list .changes-decoration-badge { +.changes-file-list .changes-decoration-badge { display: inline-flex; align-items: center; justify-content: center; @@ -189,35 +285,62 @@ min-width: 16px; font-size: 11px; font-weight: 600; - line-height: 1; margin-right: 2px; opacity: 0.9; } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.added { +.changes-file-list .changes-decoration-badge.added { color: var(--vscode-gitDecoration-addedResourceForeground); } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.modified { +.changes-file-list .changes-decoration-badge.modified { color: var(--vscode-gitDecoration-modifiedResourceForeground); } -.changes-view-body .chat-editing-session-list .changes-decoration-badge.deleted { +.changes-file-list .changes-decoration-badge.deleted { color: var(--vscode-gitDecoration-deletedResourceForeground); } + /* Line counts in list items */ -.changes-view-body .chat-editing-session-list .working-set-line-counts { +.changes-file-list .working-set-line-counts { margin: 0 6px; display: inline-flex; + align-items: center; gap: 4px; font-size: 11px; } -.changes-view-body .chat-editing-session-list .working-set-lines-added { +.changes-file-list .changes-review-comments-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + margin-right: 6px; + color: var(--vscode-descriptionForeground); +} + +.changes-file-list .changes-review-comments-badge .codicon { + font-size: 12px; +} + +.changes-file-list .changes-agent-feedback-badge { + display: inline-flex; + align-items: center; + vertical-align: middle; + gap: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.changes-file-list .changes-agent-feedback-badge .codicon { + font-size: 12px; +} + +.changes-file-list .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); } -.changes-view-body .chat-editing-session-list .working-set-lines-removed { +.changes-file-list .working-set-lines-removed { color: var(--vscode-chat-linesRemovedForeground); } @@ -235,3 +358,9 @@ .changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { color: var(--vscode-chat-linesRemovedForeground); } + +.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: 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/checksWidget.css b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css new file mode 100644 index 0000000000000..c29988b93a6da --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* CI Status Widget - beneath the files list */ +.ci-status-widget { + display: flex; + flex-direction: column; + flex-shrink: 0; + box-sizing: border-box; + overflow: hidden; + font-size: 12px; +} + +/* Header */ +.ci-status-widget-header { + position: relative; + display: flex; + align-items: center; + padding: 4px; + margin-top: 4px; + border-radius: 4px; + min-height: 22px; + font-weight: 500; + cursor: pointer; + user-select: none; +} + +.ci-status-widget-header:hover { + background-color: var(--vscode-list-hoverBackground); + padding-right: 22px; +} + +.ci-status-widget-header:focus { + padding-right: 22px; +} + +.ci-status-widget-header:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Chevron — right-aligned, visible on hover only */ +.ci-status-widget-header .group-chevron { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + visibility: hidden; + opacity: 0; +} + +.ci-status-widget-header:hover .group-chevron, +.ci-status-widget-header:focus-within .group-chevron { + visibility: visible; + opacity: 0.7; +} + +/* Title - single line, overflow ellipsis */ +.ci-status-widget-title { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + color: var(--vscode-foreground); +} + +.ci-status-widget-title-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Status count badges in the header */ +.ci-status-widget-counts { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: auto; + padding-right: 8px; +} + +.ci-status-widget-header:hover .ci-status-widget-counts, .ci-status-widget-header:focus .ci-status-widget-counts { + visibility: hidden; +} + +.ci-status-widget-count-badge { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 11px; + line-height: 1; +} + +.ci-status-widget-count-badge .codicon { + font-size: 14px; +} + +.ci-status-widget-count-badge.ci-status-success .codicon { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.ci-status-widget-count-badge.ci-status-failure .codicon { + color: var(--vscode-testing-iconFailed, #f14c4c); +} + +.ci-status-widget-count-badge.ci-status-running .codicon { + color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground)); +} + +.ci-status-widget-count-badge.ci-status-pending .codicon { + color: var(--vscode-descriptionForeground); +} + +.ci-status-widget-header-actions { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.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; +} + +/* Body - check list */ +.ci-status-widget-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.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 .ci-status-widget-list .monaco-list-row { + border-radius: 4px; +} + +.ci-status-widget-check { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.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::before { + font-size: 14px; + width: 14px; + height: 14px; +} + +.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-check.ci-status-success .monaco-icon-label::before { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.ci-status-widget-check.ci-status-failure .monaco-icon-label::before { + color: var(--vscode-testing-iconFailed, #f14c4c); +} + +.ci-status-widget-check.ci-status-running .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-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; + padding-right: 12px; + 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/src/vs/sessions/contrib/changes/common/changes.ts b/src/vs/sessions/contrib/changes/common/changes.ts new file mode 100644 index 0000000000000..28e3df1700bc4 --- /dev/null +++ b/src/vs/sessions/contrib/changes/common/changes.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 { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; + +export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; +export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; + +export const enum ChangesViewMode { + List = 'list', + Tree = 'tree' +} + +export const enum ChangesVersionMode { + BranchChanges = 'branchChanges', + OutgoingChanges = 'outgoingChanges', + AllChanges = 'allChanges', + LastTurn = 'lastTurn' +} + +export const enum IsolationMode { + Workspace = 'workspace', + Worktree = 'worktree' +} + +export const ChangesContextKeys = { + VersionMode: new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.BranchChanges), + ViewMode: new RawContextKey('sessions.changesViewMode', ChangesViewMode.List) +}; + +export const ActiveSessionContextKeys = { + IsolationMode: new RawContextKey('sessions.isolationMode', IsolationMode.Workspace), + HasChanges: new RawContextKey('sessions.hasChanges', false), + HasGitRepository: new RawContextKey('sessions.hasGitRepository', true), + HasIncomingChanges: new RawContextKey('sessions.hasIncomingChanges', false), + HasOutgoingChanges: new RawContextKey('sessions.hasOutgoingChanges', false), + HasUncommittedChanges: new RawContextKey('sessions.hasUncommittedChanges', true), + IsMergeBaseBranchProtected: new RawContextKey('sessions.isMergeBaseBranchProtected', false), + HasPullRequest: new RawContextKey('sessions.hasPullRequest', false), + HasOpenPullRequest: new RawContextKey('sessions.hasOpenPullRequest', false), +}; diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts deleted file mode 100644 index 973e8e2ba1b4b..0000000000000 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ /dev/null @@ -1,1029 +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/changesView.css'; -import * as dom from '../../../../base/browser/dom.js'; -import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; -import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; -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 { basename, dirname } from '../../../../base/common/path.js'; -import { 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 { 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 { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, 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'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; -import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js'; -import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; -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 { 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 { 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 { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; -import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; - -const $ = dom.$; - -// --- Constants - -export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; -export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; - -// --- View Mode - -export const enum ChangesViewMode { - List = 'list', - Tree = 'tree' -} - -const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); - -// --- List Item - -type ChangeType = 'added' | 'modified' | 'deleted'; - -interface IChangesFileItem { - readonly type: 'file'; - readonly uri: URI; - readonly originalUri?: URI; - readonly state: ModifiedFileEntryState; - readonly isDeletion: boolean; - readonly changeType: ChangeType; - readonly linesAdded: number; - readonly linesRemoved: number; -} - -interface IChangesFolderItem { - readonly type: 'folder'; - readonly uri: URI; - readonly name: string; -} - -interface IActiveSession { - readonly resource: URI; - readonly sessionType: string; -} - -type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; - -function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { - return element.type === 'file'; -} - -/** - * Builds a tree of `IObjectTreeElement` from a flat list of file items. - * Groups files by their directory path segments to create a hierarchical tree structure. - */ -function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement[] { - if (items.length === 0) { - return []; - } - - interface FolderNode { - name: string; - uri: URI; - children: Map; - files: IChangesFileItem[]; - } - - const root: FolderNode = { name: '', uri: URI.file('/'), children: new Map(), files: [] }; - - for (const item of items) { - const dirPath = dirname(item.uri.path); - const segments = dirPath.split('/').filter(Boolean); - - let current = root; - let currentPath = ''; - for (const segment of segments) { - currentPath += '/' + segment; - if (!current.children.has(segment)) { - current.children.set(segment, { - name: segment, - uri: item.uri.with({ path: currentPath }), - children: new Map(), - files: [] - }); - } - current = current.children.get(segment)!; - } - current.files.push(item); - } - - function convert(node: FolderNode): IObjectTreeElement[] { - const result: IObjectTreeElement[] = []; - - for (const [, child] of node.children) { - const folderElement: IChangesFolderItem = { type: 'folder', uri: child.uri, name: child.name }; - const folderChildren = convert(child); - result.push({ - element: folderElement, - children: folderChildren, - collapsible: true, - collapsed: false, - }); - } - - for (const file of node.files) { - result.push({ - element: file, - collapsible: false, - }); - } - - return result; - } - - return convert(root); -} - -// --- View Pane - -export class ChangesViewPane extends ViewPane { - - private bodyContainer: HTMLElement | undefined; - private welcomeContainer: HTMLElement | undefined; - private contentContainer: HTMLElement | undefined; - private overviewContainer: HTMLElement | undefined; - private summaryContainer: HTMLElement | undefined; - private listContainer: HTMLElement | undefined; - // Actions container is positioned outside the card for this layout experiment - private actionsContainer: HTMLElement | undefined; - - private tree: WorkbenchCompressibleObjectTree | undefined; - - private readonly renderDisposables = this._register(new DisposableStore()); - - // Track current body dimensions for list layout - 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()); - - 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, - @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, - ) { - 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(); - - // 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 ?? ''); - })); - } - - private registerBadgeTracking(): void { - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = this.activeSessionFileCountObs.read(reader); - this.updateBadge(fileCount); - })); - } - - 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; - } - - const isBackgroundSession = activeSession.sessionType === AgentSessionProviders.Background; - - 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; - } - - 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 - ? 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(); - } - } - - protected override renderBody(container: HTMLElement): void { - super.renderBody(container); - - this.bodyContainer = dom.append(container, $('.changes-view-body')); - - // Welcome message for empty state - this.welcomeContainer = dom.append(this.bodyContainer, $('.changes-welcome')); - const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); - welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); - const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); - welcomeMessage.textContent = localize('changesView.noChanges', "No files have been changed."); - - // Actions container - positioned outside and above the card - this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card')); - - // Main container with file icons support (the "card") - this.contentContainer = dom.append(this.bodyContainer, $('.chat-editing-session-container.show-file-icons')); - this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService)); - - // Toggle class based on whether the file icon theme has file icons - const updateHasFileIcons = () => { - this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons); - }; - updateHasFileIcons(); - this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); - - // Overview section (header with summary only - actions moved outside card) - this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview')); - this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary')); - - // List container - this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); - - this._register(this.onDidChangeBodyVisibility(visible => { - if (visible) { - this.onVisible(); - } else { - this.renderDisposables.clear(); - } - })); - - // Trigger initial render if already visible - if (this.isBodyVisible()) { - this.onVisible(); - } - } - - 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); - - // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.sessionType === AgentSessionProviders.Background) { - return []; - } - - const session = activeEditingSessionObs.read(reader); - if (!session) { - return []; - } - - const entries = session.entries.read(reader); - const items: IChangesFileItem[] = []; - - for (const entry of entries) { - const isDeletion = entry.isDeletion ?? false; - const linesAdded = entry.linesAdded?.read(reader) ?? 0; - const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; - - items.push({ - type: 'file', - uri: entry.modifiedURI, - originalUri: entry.originalURI, - state: entry.state.read(reader), - isDeletion, - changeType: isDeletion ? 'deleted' : 'modified', - linesAdded, - linesRemoved, - }); - } - - 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(); - } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); - - // Convert session file changes to list items (cloud/background sessions) - const sessionFilesObs = derived(reader => - [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { - const isDeletion = entry.modifiedUri === undefined; - const isAddition = entry.originalUri === undefined; - return { - type: 'file', - uri: isIChatSessionFileChange2(entry) - ? entry.modifiedUri ?? entry.uri - : entry.modifiedUri, - originalUri: entry.originalUri, - state: ModifiedFileEntryState.Accepted, - isDeletion, - changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', - linesAdded: entry.insertions, - linesRemoved: entry.deletions, - }; - }) - ); - - // Combine both entry sources for display - const combinedEntriesObs = derived(reader => { - const editEntries = editSessionEntriesObs.read(reader); - const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; - }); - - // 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; - - for (const entry of entries) { - added += entry.linesAdded; - removed += entry.linesRemoved; - } - - const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; - - return { files, added, removed, isSessionMenu }; - }); - - // 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]))); - - // 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 ?? ''); - })); - - // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); - })); - - 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(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => { - const { files } = topLevelStats.read(r); - return files > 0; - })); - - this.renderDisposables.add(autorun(reader => { - const { isSessionMenu, added, removed } = topLevelStats.read(reader); - const sessionResource = activeSessionResource.read(reader); - reader.store.add(scopedInstantiationService.createInstance( - MenuWorkbenchButtonBar, - this.actionsContainer!, - isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, - { - telemetrySource: 'changesView', - menuOptions: isSessionMenu && 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 }; - } - if (action.id === 'github.createPullRequest') { - return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; - } - return undefined; - } - } - )); - })); - } - - // Update visibility based on entries - this.renderDisposables.add(autorun(reader => { - const { files } = topLevelStats.read(reader); - const hasEntries = files > 0; - - dom.setVisibility(hasEntries, this.contentContainer!); - dom.setVisibility(hasEntries, this.actionsContainer!); - dom.setVisibility(!hasEntries, this.welcomeContainer!); - })); - - // Update summary text (line counts only, file count is shown in badge) - if (this.summaryContainer) { - dom.clearNode(this.summaryContainer); - - const linesAddedSpan = dom.$('.working-set-lines-added'); - const linesRemovedSpan = dom.$('.working-set-lines-removed'); - - this.summaryContainer.appendChild(linesAddedSpan); - this.summaryContainer.appendChild(linesRemovedSpan); - - this.renderDisposables.add(autorun(reader => { - const { added, removed } = topLevelStats.read(reader); - - linesAddedSpan.textContent = `+${added}`; - linesRemovedSpan.textContent = `-${removed}`; - })); - } - - // Create the tree - if (!this.tree && this.listContainer) { - const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); - this.tree = this.instantiationService.createInstance( - WorkbenchCompressibleObjectTree, - 'ChangesViewTree', - this.listContainer, - new ChangesTreeDelegate(), - [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar)], - { - alwaysConsumeMouseWheel: false, - accessibilityProvider: { - getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, - getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") - }, - dnd: { - getDragURI: (element: ChangesTreeElement) => element.uri.toString(), - getDragLabel: (elements) => { - const uris = elements.map(e => e.uri); - if (uris.length === 1) { - return this.labelService.getUriLabel(uris[0], { relative: true }); - } - return `${uris.length}`; - }, - dispose: () => { }, - onDragOver: () => false, - drop: () => { }, - onDragStart: (data, originalEvent) => { - try { - const elements = data.getData() as ChangesTreeElement[]; - const uris = elements.filter(isChangesFileItem).map(e => e.uri); - this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); - } catch { - // noop - } - }, - }, - identityProvider: { - getId: (element: ChangesTreeElement) => element.uri.toString() - }, - 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; - }, - } - ); - } - - // Register tree event handlers - if (this.tree) { - const tree = this.tree; - - const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean) => { - const { uri: modifiedFileUri, originalUri, isDeletion } = item; - const currentIndex = items.indexOf(item); - - const navigation = { - total: items.length, - current: currentIndex, - navigate: (index: number) => { - const target = items[index]; - if (target) { - openFileItem(target, items, false); - } - } - }; - - const group = sideBySide ? SIDE_GROUP : MODAL_GROUP; - - if (isDeletion && originalUri) { - this.editorService.openEditor({ - resource: originalUri, - options: { modal: { navigation } } - }, group); - return; - } - - if (originalUri) { - this.editorService.openEditor({ - original: { resource: originalUri }, - modified: { resource: modifiedFileUri }, - options: { modal: { navigation } } - }, group); - return; - } - - this.editorService.openEditor({ - resource: modifiedFileUri, - options: { modal: { navigation } } - }, group); - }; - - this.renderDisposables.add(tree.onDidOpen((e) => { - if (!e.element || !isChangesFileItem(e.element)) { - return; - } - - const items = combinedEntriesObs.get(); - openFileItem(e.element, items, e.sideBySide); - })); - } - - // Update tree data with combined entries - this.renderDisposables.add(autorun(reader => { - const entries = combinedEntriesObs.read(reader); - const viewMode = this.viewModeObs.read(reader); - - if (!this.tree) { - return; - } - - // Toggle list-mode class to remove tree indentation in list mode - this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List); - - if (viewMode === ChangesViewMode.Tree) { - // Tree mode: build hierarchical tree from file entries - const treeChildren = buildTreeChildren(entries); - this.tree.setChildren(null, treeChildren); - } else { - // List mode: flat list of file items - const listChildren: IObjectTreeElement[] = entries.map(item => ({ - element: item, - collapsible: false, - })); - this.tree.setChildren(null, listChildren); - } - - this.layoutTree(); - })); - } - - private layoutTree(): void { - if (!this.tree || !this.listContainer) { - return; - } - - // Calculate remaining height for the tree by subtracting other elements - const bodyHeight = this.currentBodyHeight; - if (bodyHeight <= 0) { - return; - } - - // Measure non-list elements height (padding, actions, overview) - const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body - const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; - const actionsMargin = actionsHeight > 0 ? 8 : 0; // margin-bottom on actions container - const overviewHeight = this.overviewContainer?.offsetHeight ?? 0; - 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); - - // Limit height to the content so the tree doesn't exceed its items - const contentHeight = this.tree.contentHeight; - const treeHeight = Math.min(availableHeight, contentHeight); - - this.tree.layout(treeHeight, this.currentBodyWidth); - this.tree.getHTMLElement().style.height = `${treeHeight}px`; - } - - protected override layoutBody(height: number, width: number): void { - super.layoutBody(height, width); - this.currentBodyHeight = height; - this.currentBodyWidth = width; - this.layoutTree(); - } - - override focus(): void { - super.focus(); - this.tree?.domFocus(); - } - - override dispose(): void { - this.tree?.dispose(); - this.tree = undefined; - super.dispose(); - } -} - -export class ChangesViewPaneContainer extends ViewPaneContainer { - constructor( - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @ITelemetryService telemetryService: ITelemetryService, - @IInstantiationService instantiationService: IInstantiationService, - @IContextMenuService contextMenuService: IContextMenuService, - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IConfigurationService configurationService: IConfigurationService, - @IExtensionService extensionService: IExtensionService, - @IWorkspaceContextService contextService: IWorkspaceContextService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @ILogService logService: ILogService, - ) { - super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); - } - - override create(parent: HTMLElement): void { - super.create(parent); - parent.classList.add('changes-viewlet'); - } -} - -// --- Tree Delegate & Renderer - -class ChangesTreeDelegate implements IListVirtualDelegate { - getHeight(_element: ChangesTreeElement): number { - return 22; - } - - getTemplateId(_element: ChangesTreeElement): string { - return ChangesTreeRenderer.TEMPLATE_ID; - } -} - -interface IChangesTreeTemplate { - readonly label: IResourceLabel; - readonly templateDisposables: DisposableStore; - readonly toolbar: MenuWorkbenchToolBar | undefined; - readonly contextKeyService: IContextKeyService | undefined; - readonly decorationBadge: HTMLElement; - readonly addedSpan: HTMLElement; - readonly removedSpan: HTMLElement; - readonly lineCountsContainer: HTMLElement; -} - -class ChangesTreeRenderer implements ICompressibleTreeRenderer { - static TEMPLATE_ID = 'changesTreeRenderer'; - readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID; - - constructor( - private labels: ResourceLabels, - private menuId: MenuId | undefined, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ILabelService private readonly labelService: ILabelService, - ) { } - - renderTemplate(container: HTMLElement): IChangesTreeTemplate { - const templateDisposables = new DisposableStore(); - const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); - - const lineCountsContainer = $('.working-set-line-counts'); - const addedSpan = dom.$('.working-set-lines-added'); - const removedSpan = dom.$('.working-set-lines-removed'); - lineCountsContainer.appendChild(addedSpan); - lineCountsContainer.appendChild(removedSpan); - label.element.appendChild(lineCountsContainer); - - const decorationBadge = dom.$('.changes-decoration-badge'); - label.element.appendChild(decorationBadge); - - let toolbar: MenuWorkbenchToolBar | undefined; - let contextKeyService: IContextKeyService | undefined; - if (this.menuId) { - const actionBarContainer = $('.chat-collapsible-list-action-bar'); - contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); - const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); - toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); - label.element.appendChild(actionBarContainer); - } - - return { templateDisposables, label, toolbar, contextKeyService, decorationBadge, addedSpan, removedSpan, lineCountsContainer }; - } - - renderElement(node: ITreeNode, _index: number, templateData: IChangesTreeTemplate): void { - const element = node.element; - templateData.label.element.style.display = 'flex'; - - if (isChangesFileItem(element)) { - this.renderFileElement(element, templateData); - } else { - this.renderFolderElement(element, templateData); - } - } - - renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { - const compressed = node.element; - const lastElement = compressed.elements[compressed.elements.length - 1]; - - templateData.label.element.style.display = 'flex'; - - if (isChangesFileItem(lastElement)) { - // Shouldn't happen in practice - files don't get compressed - this.renderFileElement(lastElement, templateData); - } else { - // Compressed folder chain - show joined folder names - const label = compressed.elements.map(e => isChangesFileItem(e) ? basename(e.uri.path) : e.name); - templateData.label.setResource({ resource: lastElement.uri, name: label }, { - fileKind: FileKind.FOLDER, - separator: this.labelService.getSeparator(lastElement.uri.scheme), - }); - - // Hide file-specific decorations for folders - templateData.decorationBadge.style.display = 'none'; - templateData.lineCountsContainer.style.display = 'none'; - - if (templateData.toolbar) { - templateData.toolbar.context = undefined; - } - if (templateData.contextKeyService) { - chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); - } - } - } - - private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void { - templateData.label.setFile(data.uri, { - fileKind: FileKind.FILE, - fileDecorations: undefined, - strikethrough: data.changeType === 'deleted', - hidePath: true, - }); - - // Show file-specific decorations - templateData.lineCountsContainer.style.display = ''; - templateData.decorationBadge.style.display = ''; - - // Update decoration badge (A/M/D) - const badge = templateData.decorationBadge; - badge.className = 'changes-decoration-badge'; - switch (data.changeType) { - case 'added': - badge.textContent = 'A'; - badge.classList.add('added'); - break; - case 'deleted': - badge.textContent = 'D'; - badge.classList.add('deleted'); - break; - case 'modified': - default: - badge.textContent = 'M'; - badge.classList.add('modified'); - break; - } - - templateData.addedSpan.textContent = `+${data.linesAdded}`; - templateData.removedSpan.textContent = `-${data.linesRemoved}`; - - // eslint-disable-next-line no-restricted-syntax - templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); - - if (templateData.toolbar) { - templateData.toolbar.context = data.uri; - } - if (templateData.contextKeyService) { - chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); - } - } - - private renderFolderElement(data: IChangesFolderItem, templateData: IChangesTreeTemplate): void { - templateData.label.setFile(data.uri, { - fileKind: FileKind.FOLDER, - }); - - // Hide file-specific decorations for folders - templateData.decorationBadge.style.display = 'none'; - templateData.lineCountsContainer.style.display = 'none'; - - if (templateData.toolbar) { - templateData.toolbar.context = undefined; - } - if (templateData.contextKeyService) { - chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); - } - } - - disposeTemplate(templateData: IChangesTreeTemplate): void { - templateData.templateDisposables.dispose(); - } -} - -// --- View Mode Actions - -class SetChangesListViewModeAction extends ViewAction { - constructor() { - super({ - id: 'workbench.changesView.action.setListViewMode', - title: localize('setListViewMode', "View as List"), - viewId: CHANGES_VIEW_ID, - f1: false, - icon: Codicon.listTree, - toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.List), - menu: { - id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), - group: '1_viewmode', - order: 1 - } - }); - } - - async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.List; - } -} - -class SetChangesTreeViewModeAction extends ViewAction { - constructor() { - super({ - id: 'workbench.changesView.action.setTreeViewMode', - title: localize('setTreeViewMode', "View as Tree"), - viewId: CHANGES_VIEW_ID, - f1: false, - icon: Codicon.listFlat, - toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.Tree), - menu: { - id: MenuId.ViewTitle, - when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), - group: '1_viewmode', - order: 2 - } - }); - } - - async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.Tree; - } -} - -registerAction2(SetChangesListViewModeAction); -registerAction2(SetChangesTreeViewModeAction); diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 32d2236832b09..b19a807dce3b7 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -3,37 +3,97 @@ * 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'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.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]; + const root = repo?.workingDirectory ?? repo?.uri; + if (root?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + return root; + }); + + 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]; + const root = repo?.workingDirectory ?? repo?.uri; + if (root?.scheme === AGENT_HOST_SCHEME) { + return undefined; + } + return root; + } + + setOverrideProjectRoot(root: URI): void { + this._overrideRoot.set(root, undefined); + } + + clearOverrideProjectRoot(): void { + this._overrideRoot.set(undefined, undefined); } readonly managementSections: readonly AICustomizationManagementSection[] = [ @@ -43,15 +103,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 +262,26 @@ 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.type); + return applyStorageSourceFilter([cmd], filter).length > 0; + }); + } + + private static readonly _skillUIIntegrations: ReadonlyMap = new Map([ + ['act-on-feedback', localize('skillUI.actOnFeedback', "Used by the Submit Feedback button in the Changes toolbar")], + ['generate-run-commands', localize('skillUI.generateRunCommands', "Used by the Run button in the title bar")], + ['create-pr', localize('skillUI.createPr', "Used by the Create Pull Request button in the Changes toolbar")], + ['create-draft-pr', localize('skillUI.createDraftPr', "Used by the Create Draft Pull Request button in the Changes toolbar")], + ['update-pr', localize('skillUI.updatePr', "Used by the Update Pull Request button in the Changes toolbar")], + ['merge-changes', localize('skillUI.mergeChanges', "Used by the Merge button in the Changes toolbar")], + ['commit', localize('skillUI.commit', "Used by the Commit button in the Changes toolbar")], + ]); + + getSkillUIIntegrations(): ReadonlyMap { + return SessionsAICustomizationWorkspaceService._skillUIIntegrations; + } } diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts deleted file mode 100644 index 7744e54a3dacf..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ /dev/null @@ -1,211 +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 { 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..541a975e7e1aa 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -12,13 +12,14 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; 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 { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IsActiveSessionBackgroundProviderContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; @@ -29,7 +30,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 +40,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 { CopilotCLISessionType } from '../../sessions/browser/sessionTypes.js'; export class OpenSessionWorktreeInVSCodeAction extends Action2 { static readonly ID = 'chat.openSessionWorktreeInVSCode'; @@ -46,16 +51,20 @@ 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.TitleBarSessionMenu, group: 'navigation', - order: 10, - when: IsAuxiliaryWindowContext.toNegated() + order: 9, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), }] }); } override async run(accessor: ServicesAccessor): Promise { + const telemetryService = accessor.get(ITelemetryService); + logSessionsInteraction(telemetryService, 'openInVSCode'); + const openerService = accessor.get(IOpenerService); const productService = accessor.get(IProductService); const sessionsManagementService = accessor.get(ISessionsManagementService); @@ -65,7 +74,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 === CopilotCLISessionType.id ? repo?.workingDirectory ?? repo?.uri : undefined; if (!folderUri) { return; @@ -137,11 +148,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 +204,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/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..64a59c02e353b 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,55 +12,15 @@ height: 100%; box-sizing: border-box; overflow: hidden; - padding-bottom: 10%; + padding: 16px 16px 20px 16px; container-type: size; + position: relative; } .chat-full-welcome.revealed { justify-content: center; } -/* Header */ -.chat-full-welcome-header { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - max-width: 800px; - overflow: visible; -} - -/* Watermark letterpress */ -.chat-full-welcome-letterpress { - width: 100%; - max-width: 200px; - aspect-ratio: 1/1; - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-dark.svg'); - background-size: contain; - background-position: center; - background-repeat: no-repeat; - margin-top: 8px; - margin-bottom: 20px; -} - -.vs .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-light.svg'); -} - -.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'); -} - -@container (max-height: 350px) { - .chat-full-welcome-letterpress { - display: none; - } -} - /* Input slot */ .chat-full-welcome-inputSlot { width: 100%; @@ -80,7 +40,7 @@ display: none; width: 100%; max-width: 800px; - margin: 0 0 24px 0; + margin: 0 0 8px 0; padding: 0; box-sizing: border-box; } @@ -107,6 +67,14 @@ margin-bottom: 0; } +.chat-full-welcome-content { + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: stretch; +} + /* Local mode picker (Workspace / Worktree) below input */ .chat-full-welcome-local-mode { width: 100%; @@ -116,6 +84,7 @@ display: none; flex-direction: row; align-items: center; + gap: 4px; min-height: 28px; } @@ -171,6 +140,8 @@ flex-direction: row; flex-wrap: nowrap; align-items: center; + justify-content: flex-start; + gap: 6px; width: 100%; box-sizing: border-box; padding: 0; @@ -180,96 +151,47 @@ 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; -} - -/* Right half: pickers, left-justified */ -.sessions-chat-pickers-right-half { - flex: 1; - display: flex; - justify-content: flex-start; - align-items: center; - min-width: 0; -} - -/* 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; -} - -/* 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-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; +.chat-full-welcome-pickers-label { + font-size: 18px; + line-height: 1.25; 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; +/* Project picker in inline title row */ +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { + height: auto; + padding: 4px; + font-size: 18px; + line-height: 1.25; border: none; - white-space: nowrap; + background-color: transparent; + color: var(--vscode-foreground); border-radius: 4px; } -.sessions-chat-dropdown-button:hover { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label: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-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { + font-size: 18px; } -.sessions-chat-dropdown-button .codicon { - font-size: 14px; - flex-shrink: 0; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { + font-size: 16px; + margin: 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-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + margin-left: 6px; + line-height: 1; + transform: translateY(1px); } .sessions-chat-dropdown-label { @@ -291,7 +213,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 +235,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 { @@ -349,8 +277,13 @@ } .sessions-chat-picker-slot .action-label .codicon-chevron-down { + display: inline-flex; + align-items: center; + justify-content: center; font-size: 12px; - margin-left: 2px; + margin-left: 6px; + line-height: 1; + transform: translateY(1px); } .sessions-chat-picker-slot .action-label .chat-session-option-label { @@ -361,3 +294,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..36a56d88295c1 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; + padding: 0 8px 6px 10px; 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 { @@ -139,7 +172,9 @@ } @keyframes sessions-chat-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* Attach row (pills only, above editor, inside input area) */ @@ -222,6 +257,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 +304,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..db84e8a5da921 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -0,0 +1,186 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + padding: 16px; +} + +.run-script-action-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.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 { + display: inline-flex; + width: fit-content; + max-width: 100%; + gap: 2px; + padding: 2px; + border: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.06)); + border-radius: 6px; + background-color: var(--vscode-input-background); +} + +.run-script-action-section .monaco-custom-radio > .monaco-button { + width: fit-content; + min-width: 76px; + padding: 4px 8px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + font-size: 12px; + font-weight: 500; + line-height: 14px; + opacity: 0.9; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, opacity 120ms ease; +} + +.run-script-action-section .monaco-custom-radio > .monaco-button:first-child, +.run-script-action-section .monaco-custom-radio > .monaco-button:last-child { + border-radius: 4px; +} + +.run-script-action-section .monaco-custom-radio > .monaco-button:not(.active):not(:last-child), +.run-script-action-section .monaco-custom-radio > .monaco-button.previous-active { + border-right: 1px solid transparent; + border-left: 1px solid transparent; +} + +.run-script-action-section .monaco-custom-radio > .monaco-button:hover:not(.active):not(.disabled) { + background-color: var(--vscode-list-hoverBackground); + border-color: rgba(255, 255, 255, 0.06); + opacity: 1; +} + +.run-script-action-section .monaco-custom-radio > .monaco-button.active, +.run-script-action-section .monaco-custom-radio > .monaco-button.active:hover { + background-color: var(--vscode-quickInputList-focusBackground); + color: var(--vscode-quickInputList-focusForeground); + border-color: var(--vscode-focusBorder, transparent); + opacity: 1; +} + +.run-script-action-section .monaco-custom-radio > .monaco-button:focus { + outline-offset: 0 !important; +} + +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled { + opacity: 0.75; +} + +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button, +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button:hover:not(.active) { + background-color: transparent; + border-color: transparent; + color: var(--vscode-disabledForeground); + opacity: 1; + cursor: default; +} + +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button.active, +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button.active:hover { + background-color: var(--vscode-quickInputList-focusBackground); + color: var(--vscode-quickInputList-focusForeground); + border-color: var(--vscode-focusBorder, transparent); + opacity: 0.9; + cursor: default; +} + +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button:focus, +.run-script-action-section .monaco-custom-radio.run-script-action-radio-disabled > .monaco-button.active:focus { + outline: none !important; +} + +.run-script-action-hint { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.run-script-action-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} + +.run-script-action-buttons .monaco-text-button { + width: fit-content; + min-height: 28px; + padding: 5px 12px; + border-radius: 6px; +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-titlebar { + gap: 8px; + height: 32px; + padding: 0 6px 0 0; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom: 1px solid var(--vscode-titleBar-border, var(--vscode-widget-border, transparent)); + box-sizing: border-box; + background-color: var(--vscode-titleBar-activeBackground); +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-right-action-bar { + margin-right: 0; +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-title { + padding: 0; + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--vscode-titleBar-activeForeground); +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget { + border-radius: 8px; + box-shadow: var(--vscode-shadow-xl); +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-html-widget { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + overflow: hidden; +} + +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .quick-input-header, +.agent-sessions-workbench.run-script-action-modal-visible .quick-input-widget .run-script-action-widget { + background-color: var(--vscode-editor-background); +} + +.run-script-action-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 2549; +} diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts b/src/vs/sessions/contrib/chat/browser/modePicker.ts similarity index 64% rename from src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts rename to src/vs/sessions/contrib/chat/browser/modePicker.ts index 99549752c8ab5..a4a092d83492f 100644 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/modePicker.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/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts new file mode 100644 index 0000000000000..a4a092d83492f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.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/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..b2bfab825e2fd --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.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'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { URI } from '../../../../base/common/uri.js'; +import { CopilotChatSessionsProvider } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.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; + } + + set permissionLevel(level: ChatPermissionLevel) { + this._currentLevel = level; + this._updateTriggerLabel(this._triggerElement); + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + @IOpenerService private readonly openerService: IOpenerService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + 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) { + return; + } + this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId)?.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, + }); + } + + items.push({ + kind: ActionListItemKind.Separator, + label: '', + disabled: false, + }); + items.push({ + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.blank }, + item: { + label: localize('permissions.learnMore', "Learn more about permissions"), + icon: Codicon.blank, + checked: false, + }, + label: localize('permissions.learnMore', "Learn more about permissions"), + hideIcon: false, + disabled: false, + }); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: async (item) => { + this.actionWidgetService.hide(); + if (item.level) { + await this._selectLevel(item.level); + } else { + await this.openerService.open(URI.parse('https://code.visualstudio.com/docs/copilot/agents/agent-tools#_permission-levels')); + } + }, + 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..1b7d6cc9efe13 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,87 @@ 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(); + // When a workspace is selected, create a new session + this._register(this._workspacePicker.onDidChangeSelection(() => { + this._renderOptionGroupPickers(); })); - - this._register(this._branchPicker.onDidChange(() => { + 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,21 +163,26 @@ 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 - const header = dom.append(welcomeElement, dom.$('.chat-full-welcome-header')); - dom.append(header, dom.$('.chat-full-welcome-letterpress')); + // Main empty-state content area (folder picker, input, local mode controls) + const welcomeContent = dom.append(welcomeElement, dom.$('.chat-full-welcome-content')); // Option group pickers (above the input) - this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); + this._pickersContainer = dom.append(welcomeContent, dom.$('.chat-full-welcome-pickers-container')); // Input slot - this._inputSlot = dom.append(welcomeElement, dom.$('.chat-full-welcome-inputSlot')); + this._inputSlot = dom.append(welcomeContent, dom.$('.chat-full-welcome-inputSlot')); // 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 +190,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(welcomeContent, 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 +237,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 +261,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 +285,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 +380,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 +396,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 +407,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 +430,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 +437,252 @@ class NewChatWidget extends Disposable { return; } - this._clearExtensionPickers(); dom.clearNode(this._pickersContainer); const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); + const pickersLabel = dom.append(pickersRow, dom.$('.chat-full-welcome-pickers-label')); + pickersLabel.textContent = this._workspacePicker.selectedProject + ? localize('newSessionIn', "New session in") + : localize('newSessionChooseWorkspace', "Start by picking a"); - // 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.sendAndCreateChat(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 { + if (selection.workspace.requiresWorkspaceTrust) { + 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(); - }); + this._createNewSession(selection); } - // --- Layout --- - - layout(_height: number, _width: number): void { - this._editor?.layout(); + 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(); + } } - focusInput(): void { - this._editor?.focus(); + sendQuery(text: string): void { + const model = this._editor?.getModel(); + if (model) { + model.setValue(text); + this._send(); + } } - updateAllowedTargets(targets: AgentSessionProviders[]): void { - this._targetPicker.updateAllowedTargets(targets); + selectWorkspace(workspace: IWorkspaceSelection): void { + this._workspacePicker.setSelectedWorkspace(workspace); } } @@ -839,7 +710,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 +719,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 +735,33 @@ export class NewChatViewPane extends ViewPane { this._widget?.focusInput(); } + prefillInput(text: string): void { + this._widget?.prefillInput(text); + } + + sendQuery(text: string): void { + this._widget?.sendQuery(text); + } + + selectWorkspace(workspace: IWorkspaceSelection): void { + this._widget?.selectWorkspace(workspace); + } + 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..63d6748ed4e86 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -6,23 +6,207 @@ 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 } 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 skills bundled with the Agents app. */ +export const BUILTIN_SKILLS_URI = FileAccess.asFileUri('vs/sessions/skills'); export class AgenticPromptsService extends PromptsService { + private _copilotRoot: URI | undefined; + private _builtinSkillsCache: Promise | undefined; + protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); } + + private getCopilotRoot(): URI { + if (!this._copilotRoot) { + this._copilotRoot = joinPath(this.pathService.userHome({ preferLocal: true }), '.copilot'); + } + return this._copilotRoot; + } + + //#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 { + try { + const stat = await this.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): Promise { + const baseResult = await super.findAgentSkills(token); + 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 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); + + if (type !== PromptsType.skill) { + return baseResults; + } + + const builtinItems = await this.getBuiltinSkillPaths(); + 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(basename(dirname(p.uri))); + } + } + + const nonOverridden = builtinItems.filter( + p => !overriddenNames.has(basename(dirname(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; + } + // Built-in storage is only valid for skills; for other types, there are no items. + return []; + } + 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 +220,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 +231,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { searchService, userDataService, logService, - pathService + pathService, + workspaceTrustManagementService ); } @@ -64,7 +250,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 +263,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 +276,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..3001d349a889e 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -3,29 +3,55 @@ * 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 { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.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 { IQuickInputButton, 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 { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IsActiveSessionBackgroundProviderContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ISession } 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 { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.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'); +const RUN_SCRIPT_ACTION_MODAL_VISIBLE_CLASS = 'run-script-action-modal-visible'; // 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'; +const closeQuickWidgetButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('closeQuickWidget', "Close"), + alwaysVisible: true, +}; function getTaskDisplayLabel(task: ITaskEntry): string { if (task.label && task.label.length > 0) { @@ -43,12 +69,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: ISession; + 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. @@ -60,9 +116,13 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr private readonly _activeRunState: IObservable; constructor( - @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @IKeybindingService _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -72,77 +132,106 @@ 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); + const activeSession = this._sessionManagementService.activeSession.read(reader); if (!activeSession) { return undefined; } 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: ISession) => that._showConfigureQuickPick(session), + (session: ISession, 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; + } + + logSessionsInteraction(that._telemetryService, 'runPrimaryTask'); + + 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 +242,42 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } async run(): Promise { - await that._showConfigureQuickPick(session); + logSessionsInteraction(that._telemetryService, 'addTask'); + 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 { + logSessionsInteraction(that._telemetryService, 'generateNewTask'); + await that._sessionManagementService.sendAndCreateChat(session, { query: '/generate-run-commands' }); } })); })); } - private async _showConfigureQuickPick(session: IActiveSessionItem): Promise { + private async _showConfigureQuickPick(session: ISession): 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 +289,480 @@ 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' }, 'add', true); } else { // Custom command path - await this._showCustomCommandInput(session); + return this._showCustomCommandInput(session, undefined, 'add', true); } } - 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: ISession, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add', allowBackNavigation = false): Promise { + const taskConfiguration = await this._showCustomCommandWidget(session, existingTask, mode, allowBackNavigation); + if (!taskConfiguration) { + return undefined; } + if (taskConfiguration === 'back') { + return this._showConfigureQuickPick(session); + } + + 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; + } - const target = await this._pickStorageTarget(session); - if (!target) { - return; + 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: ISession, existingTask?: INonSessionTaskEntry, mode: TaskConfigurationMode = 'add', allowBackNavigation = false): 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; + quickWidget.buttons = allowBackNavigation + ? [this._quickInputService.backButton, closeQuickWidgetButton] + : [closeQuickWidgetButton]; + 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; + this._layoutService.mainContainer.classList.add(RUN_SCRIPT_ACTION_MODAL_VISIBLE_CLASS); + const backdrop = append(this._layoutService.mainContainer, $('.run-script-action-modal-backdrop')); + disposables.add(addDisposableListener(backdrop, EventType.MOUSE_DOWN, e => { + e.preventDefault(); + e.stopPropagation(); + complete(undefined); + })); + disposables.add({ dispose: () => backdrop.remove() }); + disposables.add({ dispose: () => this._layoutService.mainContainer.classList.remove(RUN_SCRIPT_ACTION_MODAL_VISIBLE_CLASS) }); - 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.onDidTriggerButton(button => { + if (allowBackNavigation && button === this._quickInputService.backButton) { + settled = true; + resolve('back'); + quickWidget.hide(); + return; + } + if (button === closeQuickWidgetButton) { + complete(undefined); + } + })); + disposables.add(quickWidget.onDidHide(() => { + if (!settled) { + settled = true; + resolve(undefined); + } + disposables.dispose(); + })); - const items: IStorageTargetItem[] = [ + 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 { + + 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: ISession) => Promise, + private readonly _showCustomCommandInput: (session: ISession, 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, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + ) { + 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 task 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 task creation and management + const tasksCategory = { label: localize('tasksActionsCategory', "Tasks"), 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: tasksCategory, + 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: tasksCategory, + run: async () => { + await this._sessionsManagementService.sendAndCreateChat(session, { query: '/generate-run-commands' }); + }, + }); + + return actions; + } +} + +/** + * {@link ActionWidgetDropdownActionViewItem} that renders a chevron-down icon + * for the split button dropdown in the titlebar. + */ +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..dd6a9f7477972 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts @@ -0,0 +1,245 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + if (storageDisabledReason) { + dom.append(storageSection, dom.$('div.run-script-action-hint', undefined, storageDisabledReason)); + } + 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")); + this._storageOptions.domNode.classList.toggle('run-script-action-radio-disabled', this._targetLocked); + this._storageOptions.setEnabled(!this._targetLocked); + storageSection.appendChild(this._storageOptions.domNode); + + 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('confirmAddToAgents', "Add to Agents 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..3425f9f2c4df8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.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 * 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 { 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, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + ) { + super(); + + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + if (session) { + this._sessionTypes = this.sessionsManagementService.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 || !this._slotElement) { + 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; + dom.setVisibility(hasMultipleTypes, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!hasMultipleTypes)); + this._triggerElement.tabIndex = hasMultipleTypes ? 0 : -1; + 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..395ddcf86effc --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -0,0 +1,812 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { SubmenuAction, toAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, MutableDisposable } 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 { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; +import { IOutputService } from '../../../../workbench/services/output/common/output.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ISessionWorkspace } from '../../sessions/common/sessionData.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsBrowseAction, ISessionsProvider } 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; + /** Remote provider reference for gear menu actions. */ + readonly remoteProvider?: ISessionsProvider; +} + +/** + * 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 readonly _onDidChangeSelection = this._register(new Emitter()); + readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; + + private _selectedWorkspace: IWorkspaceSelection | undefined; + + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _connectionStatusListener = this._register(new MutableDisposable()); + + 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, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IPreferencesService private readonly preferencesService: IPreferencesService, + @IOutputService private readonly outputService: IOutputService, + ) { + super(); + + // Migrate legacy storage to new key + this._migrateLegacyStorage(); + + // Restore selected workspace from storage + this._selectedWorkspace = this._restoreSelectedWorkspace(); + + // React to provider registrations/removals: re-validate the current + // selection and attempt to restore a stored workspace when none is active. + this._register(this.sessionsProvidersService.onDidChangeProviders(() => { + if (this._selectedWorkspace) { + // Validate that the selected workspace's provider is still registered + const providers = this.sessionsProvidersService.getProviders(); + if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) { + this._selectedWorkspace = undefined; + this._updateTriggerLabel(); + } + } + if (!this._selectedWorkspace) { + const restored = this._restoreSelectedWorkspace(); + if (restored) { + this._selectedWorkspace = restored; + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + this._onDidSelectWorkspace.fire(restored); + } + } + this._watchConnectionStatus(); + })); + + this._watchConnectionStatus(); + } + + /** + * 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'; + trigger.setAttribute('aria-haspopup', 'listbox'); + trigger.setAttribute('aria-expanded', 'false'); + 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.selection && this._isProviderUnavailable(item.selection.providerId)) { + // Workspace belongs to an unavailable remote — ignore selection + return; + } + if (item.remoteProvider && item.browseActionIndex === undefined) { + // Disconnected remote host — show options menu after widget hides + this._showRemoteHostOptionsDelayed(item.remoteProvider); + } else if (item.browseActionIndex !== undefined) { + this._executeBrowseAction(item.browseActionIndex); + } else if (item.selection) { + this._selectProject(item.selection); + } + }, + onHide: () => { + triggerElement.setAttribute('aria-expanded', 'false'); + triggerElement.focus(); + }, + }; + + const listOptions = showFilter + ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false } + : { reserveSubmenuSpace: false }; + triggerElement.setAttribute('aria-expanded', 'true'); + + 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. + */ + setSelectedWorkspace(project: IWorkspaceSelection, fireEvent = true): void { + this._selectProject(project, fireEvent); + } + + /** + * Clears the selected project. + */ + clearSelection(): void { + this.actionWidgetService.hide(); + 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(); + this._onDidChangeSelection.fire(); + } + + /** + * 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(); + this._onDidChangeSelection.fire(); + 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, showing provider name as description on the first entry + const providersWithWorkspaces = allProviders.filter(p => recentWorkspaces.some(w => w.providerId === p.id)); + for (let pi = 0; pi < providersWithWorkspaces.length; pi++) { + const provider = providersWithWorkspaces[pi]; + const isOffline = this._isProviderUnavailable(provider.id); + const providerWorkspaces = recentWorkspaces.filter(w => w.providerId === provider.id); + for (let i = 0; i < providerWorkspaces.length; i++) { + const { workspace, providerId } = providerWorkspaces[i]; + const selection: IWorkspaceSelection = { providerId, workspace }; + const selected = this._isSelectedWorkspace(selection); + const description = i === 0 + ? (isOffline ? localize('workspacePicker.providerOffline', "{0} (Offline)", provider.label) : provider.label) + : (isOffline ? localize('workspacePicker.offline', "Offline") : undefined); + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + description, + group: { title: '', icon: workspace.icon }, + item: { selection, checked: selected || undefined }, + onRemove: () => this._removeRecentWorkspace(selection), + }); + } + if (pi < providersWithWorkspaces.length - 1) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + } + } else { + for (const { workspace, providerId } of recentWorkspaces) { + const selection: IWorkspaceSelection = { providerId, workspace }; + const selected = this._isSelectedWorkspace(selection); + const isOffline = this._isProviderUnavailable(providerId); + items.push({ + kind: ActionListItemKind.Action, + label: workspace.label, + description: isOffline ? localize('workspacePicker.offlineSingle', "Offline") : undefined, + group: { title: '', icon: workspace.icon }, + item: { selection, checked: selected || undefined }, + onRemove: () => this._removeRecentWorkspace(selection), + }); + } + } + + // Browse actions from all providers + const allBrowseActions = this._getAllBrowseActions(); + // Remote providers with connection status + const remoteProviders = allProviders.filter(p => p.connectionStatus !== undefined); + + if (items.length > 0 && (allBrowseActions.length > 0 || remoteProviders.length > 0)) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + if (hasMultipleProviders && (allBrowseActions.length + remoteProviders.length) > 1) { + // Show a single "Select..." entry with provider-grouped submenu actions + // that also includes remote host entries + const providerMap = new Map(); + allBrowseActions.forEach((action, i) => { + let entry = providerMap.get(action.providerId); + if (!entry) { + const provider = allProviders.find(p => p.id === action.providerId); + if (!provider) { return; } + entry = { provider, actions: [] }; + providerMap.set(action.providerId, entry); + } + entry.actions.push({ action, index: i }); + }); + const remoteProviderIds = new Map(remoteProviders.map(p => [p.id, p])); + const submenuActions = [...providerMap.values()].map(({ provider, actions }) => { + const remoteProvider = remoteProviderIds.get(provider.id); + const remoteStatus = remoteProvider?.connectionStatus?.get(); + const actionItems = actions.map(({ action, index }, ci) => toAction({ + id: `workspacePicker.browse.${index}`, + label: localize(`workspacePicker.browseAction`, "{0}...", action.label), + tooltip: ci === 0 ? provider.label : '', + enabled: remoteStatus !== RemoteAgentHostConnectionStatus.Disconnected && remoteStatus !== RemoteAgentHostConnectionStatus.Connecting, + run: () => this._executeBrowseAction(index), + })); + + return new SubmenuAction( + `workspacePicker.browse.${provider.id}`, + '', + actionItems, + ); + }); + + items.push({ + kind: ActionListItemKind.Action, + label: localize('workspacePicker.browseSelect', "Select..."), + group: { title: '', icon: Codicon.folderOpened }, + item: {}, + submenuActions, + }); + } else { + for (let i = 0; i < allBrowseActions.length; i++) { + const action = allBrowseActions[i]; + items.push({ + kind: ActionListItemKind.Action, + label: localize(`workspacePicker.browseSelectAction`, "Select {0}...", action.label), + group: { title: '', icon: action.icon }, + item: { browseActionIndex: i }, + }); + } + } + + for (const provider of remoteProviders) { + const status = provider.connectionStatus!.get(); + const isConnected = status === RemoteAgentHostConnectionStatus.Connected; + const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id); + + if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + + items.push({ + kind: ActionListItemKind.Action, + label: provider.label, + description: this._getStatusDescription(status), + hover: { content: this._getStatusHover(status, provider.remoteAddress) }, + group: { title: '', icon: Codicon.remote }, + disabled: !isConnected, + item: { + browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined, + remoteProvider: provider, + }, + toolbarActions: [ + toAction({ + id: `workspacePicker.remote.gear.${provider.id}`, + label: localize('workspacePicker.remoteOptions', "Options"), + class: ThemeIcon.asClassName(Codicon.gear), + run: () => { + this.actionWidgetService.hide(); + this._showRemoteHostOptionsDelayed(provider); + }, + }), + ], + }); + } + + return items; + } + + /** + * Returns a short status indicator with a colored circle icon for the description field. + */ + private _getStatusDescription(status: RemoteAgentHostConnectionStatus): MarkdownString { + const md = new MarkdownString(undefined, { supportThemeIcons: true }); + switch (status) { + case RemoteAgentHostConnectionStatus.Connected: + md.appendText(localize('workspacePicker.statusOnline', "Online")); + break; + case RemoteAgentHostConnectionStatus.Connecting: + md.appendText(localize('workspacePicker.statusConnecting', "Connecting")); + break; + case RemoteAgentHostConnectionStatus.Disconnected: + md.appendText(localize('workspacePicker.statusOffline', "Offline")); + break; + } + return md; + } + + /** + * Returns detailed hover text for a remote host's connection status. + */ + private _getStatusHover(status: RemoteAgentHostConnectionStatus, address?: string): string { + switch (status) { + case RemoteAgentHostConnectionStatus.Connected: + return address + ? localize('workspacePicker.hoverConnectedAddr', "Remote agent host is connected and ready.\n\nAddress: {0}", address) + : localize('workspacePicker.hoverConnected', "Remote agent host is connected and ready."); + case RemoteAgentHostConnectionStatus.Connecting: + return address + ? localize('workspacePicker.hoverConnectingAddr', "Attempting to connect to remote agent host...\n\nAddress: {0}", address) + : localize('workspacePicker.hoverConnecting', "Attempting to connect to remote agent host..."); + case RemoteAgentHostConnectionStatus.Disconnected: + return address + ? localize('workspacePicker.hoverDisconnectedAddr', "Remote agent host is disconnected. Click the gear icon for options.\n\nAddress: {0}", address) + : localize('workspacePicker.hoverDisconnected', "Remote agent host is disconnected. Click the gear icon for options."); + } + } + + /** + * Show the remote host options quickpick after a short delay. + * This ensures the action widget has fully hidden before the quickpick opens, + * preventing focus conflicts that cause the quickpick to flash and disappear. + */ + private _showRemoteHostOptionsDelayed(provider: ISessionsProvider): void { + const timeout = setTimeout(() => this._showRemoteHostOptions(provider), 1); + this._renderDisposables.add({ dispose: () => clearTimeout(timeout) }); + } + + private async _showRemoteHostOptions(provider: ISessionsProvider): Promise { + const address = provider.remoteAddress; + if (!address) { + return; + } + + const status = provider.connectionStatus?.get(); + const isConnected = status === RemoteAgentHostConnectionStatus.Connected; + + const items: IQuickPickItem[] = []; + if (!isConnected) { + items.push({ label: '$(debug-restart) ' + localize('workspacePicker.reconnect', "Reconnect"), id: 'reconnect' }); + } + items.push( + { label: '$(trash) ' + localize('workspacePicker.removeRemote', "Remove Remote"), id: 'remove' }, + { label: '$(copy) ' + localize('workspacePicker.copyAddress', "Copy Address"), id: 'copy' }, + { label: '$(settings-gear) ' + localize('workspacePicker.openSettings', "Open Settings"), id: 'settings' }, + ); + if (provider.outputChannelId) { + items.push({ label: '$(output) ' + localize('workspacePicker.showOutput', "Show Output"), id: 'output' }); + } + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('workspacePicker.remoteOptionsTitle', "Options for {0}", provider.label), + }); + if (!picked) { + return; + } + + const action = (picked as IQuickPickItem & { id: string }).id; + switch (action) { + case 'reconnect': + this.remoteAgentHostService.reconnect(address); + break; + case 'remove': + await this.remoteAgentHostService.removeRemoteAgentHost(address); + break; + case 'copy': + await this.clipboardService.writeText(address); + break; + case 'settings': + await this.preferencesService.openSettings({ query: 'chat.remoteAgentHosts' }); + break; + case 'output': + if (provider.outputChannelId) { + this.outputService.showChannel(provider.outputChannelId, true); + } + break; + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const workspace = this._selectedWorkspace?.workspace; + const label = workspace ? workspace.label : localize('pickWorkspace', "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)).classList.add('sessions-chat-dropdown-chevron'); + } + + /** + * Returns whether the given provider is a remote that is currently unavailable + * (disconnected or still connecting). + * Returns false for providers without connection status (e.g. local providers). + */ + private _isProviderUnavailable(providerId: string): boolean { + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === providerId); + if (!provider?.connectionStatus) { + return false; + } + return provider.connectionStatus.get() !== RemoteAgentHostConnectionStatus.Connected; + } + + /** + * Watch connection status observables from all remote providers. + * When a remote disconnects, clear the selection if it belongs to that + * provider. When a remote reconnects, try to restore a stored workspace. + */ + private _watchConnectionStatus(): void { + const remoteProviders = this.sessionsProvidersService.getProviders().filter(p => p.connectionStatus !== undefined); + if (remoteProviders.length === 0) { + this._connectionStatusListener.clear(); + return; + } + + this._connectionStatusListener.value = autorun(reader => { + for (const provider of remoteProviders) { + provider.connectionStatus!.read(reader); + } + + // If the current selection belongs to an unavailable provider, clear it + if (this._selectedWorkspace && this._isProviderUnavailable(this._selectedWorkspace.providerId)) { + this._selectedWorkspace = undefined; + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + } + + // If no selection, try to restore the previously checked workspace + // (only the checked entry, not any fallback, to avoid unexpected switches) + if (!this._selectedWorkspace) { + const restored = this._restoreCheckedWorkspace(); + if (restored) { + this._selectedWorkspace = restored; + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + this._onDidSelectWorkspace.fire(restored); + } + } + }); + } + + private _isSelectedWorkspace(selection: IWorkspaceSelection): boolean { + if (!this._selectedWorkspace) { + return false; + } + if (this._selectedWorkspace.providerId !== selection.providerId) { + return false; + } + const selectedUri = this._selectedWorkspace.workspace.repositories[0]?.uri; + const candidateUri = selection.workspace.repositories[0]?.uri; + return this.uriIdentityService.extUri.isEqual(selectedUri, candidateUri); + } + + private _persistSelectedWorkspace(selection: IWorkspaceSelection): void { + const uri = selection.workspace.repositories[0]?.uri; + if (!uri) { + return; + } + this._addRecentWorkspace(selection.providerId, selection.workspace, true); + } + + private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined { + // Try the checked entry first + const checked = this._restoreCheckedWorkspace(); + if (checked) { + return checked; + } + + // Fall back to the first resolvable recent workspace from a connected provider + try { + const providers = this._getActiveProviders(); + const providerIds = new Set(providers.map(p => p.id)); + const storedRecents = this._getStoredRecentWorkspaces(); + + for (const stored of storedRecents) { + if (!providerIds.has(stored.providerId)) { + continue; + } + if (this._isProviderUnavailable(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; + } + } + + /** + * Restore only the checked (previously selected) workspace if its provider + * is currently available. Does not fall back to other workspaces. + * Used by the connection status watcher to avoid unexpected workspace switches. + */ + private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined { + try { + const providers = this._getActiveProviders(); + const providerIds = new Set(providers.map(p => p.id)); + const storedRecents = this._getStoredRecentWorkspaces(); + + for (const stored of storedRecents) { + if (!stored.checked || !providerIds.has(stored.providerId)) { + continue; + } + if (this._isProviderUnavailable(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 all other entries when marking checked + if (checked && p.checked) { + 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 _removeRecentWorkspace(selection: IWorkspaceSelection): void { + const uri = selection.workspace.repositories[0]?.uri; + if (!uri) { + return; + } + const recents = this._getStoredRecentWorkspaces(); + const updated = recents.filter(p => + !(p.providerId === selection.providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) + ); + this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); + + // Clear current selection if it was the removed workspace + if (this._isSelectedWorkspace(selection)) { + this.actionWidgetService.hide(); + this._selectedWorkspace = undefined; + this._updateTriggerLabel(); + this._onDidChangeSelection.fire(); + } + } + + 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..599e524c9fb28 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -7,19 +7,24 @@ 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 { ISession } 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 +35,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 +67,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: ISession): IObservable; /** * Returns tasks that do NOT have `inSessions: true` — used as * suggestions in the "Add Run Action" picker. */ - getNonSessionTasks(session: IActiveSessionItem): Promise; + getNonSessionTasks(session: ISession): 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: ISession, 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: ISession, 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: ISession, 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: ISession, 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: ISession): 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 +124,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 +137,17 @@ 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, - @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ITaskService private readonly _taskService: ITaskService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @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: ISession): IObservable { + const repo = this._getSessionRepo(session); + const folder = repo?.workingDirectory ?? repo?.uri; if (folder) { this._ensureFileWatch(folder); } @@ -124,12 +159,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: ISession): 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: ISession, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -142,28 +198,35 @@ 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 (target === 'workspace') { - await this._commitTasksFile(session); + 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); } - async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async createAndAddTask(label: string | undefined, command: string, session: ISession, 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, [ @@ -171,78 +234,128 @@ export class SessionsConfigurationService extends Disposable implements ISession { path: ['tasks'], value: [...tasks, newTask] } ], true); - if (target === 'workspace') { - await this._commitTasksFile(session); + return newTask; + } + + async updateTask(originalTaskLabel: string, updatedTask: ITaskEntry, session: ISession, currentTarget: TaskStorageTarget, newTarget: TaskStorageTarget): Promise { + const currentTasksJsonUri = this._getTasksJsonUri(session, currentTarget); + const newTasksJsonUri = this._getTasksJsonUri(session, newTarget); + if (!currentTasksJsonUri || !newTasksJsonUri) { + return; + } + + const currentTasksJson = await this._readTasksJson(currentTasksJsonUri); + const currentTasks = currentTasksJson.tasks ?? []; + const currentIndex = currentTasks.findIndex(task => task.label === originalTaskLabel); + if (currentIndex === -1) { + return; + } + + 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); + } + + const repoUri = this._getSessionRepo(session)?.uri; + if (repoUri) { + const key = repoUri.toString(); + if (this._pinnedTaskLabels.get(key) === originalTaskLabel) { + this._setPinnedTaskLabelForKey(key, updatedTask.label); + } } } - async runTask(task: ITaskEntry, session: IActiveSessionItem): Promise { - const command = this._resolveCommand(task); - if (!command) { + async removeTask(taskLabel: string, session: ISession, target: TaskStorageTarget): Promise { + const tasksJsonUri = this._getTasksJsonUri(session, target); + if (!tasksJsonUri) { return; } - const cwd = session.worktree ?? session.repository; + 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); + + 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: ISession): Promise { + const repo = this._getSessionRepo(session); + const cwd = repo?.workingDirectory ?? repo?.uri; if (!cwd) { 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); + const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd); + if (!workspaceFolder) { + return; } - 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)); - } + + const resolvedTask = await this._taskService.getTask(workspaceFolder, task.label); + if (!resolvedTask) { + return; } + + await this._taskService.run(resolvedTask, undefined, TaskRunSource.User); } - getLastRunTaskLabel(repository: URI | undefined): IObservable { + 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: ISession) { + return session.workspace.get()?.repositories[0]; } - private _getTasksJsonUri(session: IActiveSessionItem, target: TaskStorageTarget): URI | undefined { + private _getTasksJsonUri(session: ISession, 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 +370,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 +383,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,27 +407,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 - if (!worktree) { - return; - } - const tasksUri = joinPath(worktree, '.vscode', 'tasks.json'); - 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 +433,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..8d97620bb9dc6 --- /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.uri; + const typeLabel = promptCommand.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.userInvocable); + 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/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts new file mode 100644 index 0000000000000..d6b0fb72fd0be --- /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 Agents 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/runScriptAction.test.ts b/src/vs/sessions/contrib/chat/test/browser/runScriptAction.test.ts new file mode 100644 index 0000000000000..af18d3b5463d6 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/runScriptAction.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isISubmenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; + +// Side-effect import to trigger module-level menu registrations. +// Only import runScriptAction which registers the Run dropdown submenu; +// avoid chat.contribution.js and sessionsTerminalContribution.js which +// bootstrap heavy workbench contributions and leak disposables from +// KeybindingsRegistry in this lightweight test context. +import '../../browser/runScriptAction.js'; + +const titleBarSessionMenu = MenuId.for('SessionsTitleBarSessionMenu'); + +suite('RunScriptContribution', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('contributes run dropdown to TitleBarSessionMenu', () => { + const items = MenuRegistry.getMenuItems(titleBarSessionMenu); + + const runAction = items.find(item => isISubmenuItem(item) && item.submenu.id === 'AgentSessionsRunScriptDropdown'); + + assert.ok(runAction, 'run dropdown should be contributed to TitleBarSessionMenu'); + assert.strictEqual(runAction.order, 8); + }); +}); 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/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts new file mode 100644 index 0000000000000..af522c20d3372 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionType } from '../../../sessions/browser/sessionsProvider.js'; +import { SessionStatus } from '../../../sessions/common/sessionData.js'; +import { SessionTypePicker } from '../../browser/sessionTypePicker.js'; + +function createActiveSession(sessionType: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionType}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId: `provider:${sessionType}`, + resource: URI.parse(`test:///session/${sessionType}`), + providerId: 'provider', + sessionType, + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +suite('SessionTypePicker', () => { + + const disposables = new DisposableStore(); + let sessionTypes: ISessionType[]; + let activeSession: ReturnType>; + let instantiationService: TestInstantiationService; + + setup(() => { + sessionTypes = []; + activeSession = observableValue('activeSession', undefined); + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + getSessionTypes: () => sessionTypes, + setSessionType: () => { + throw new Error('Not implemented'); + }, + }); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('hides the picker when only one session type is available', () => { + sessionTypes = [{ id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, 'none'); + }); + + test('shows the picker when multiple session types are available', () => { + sessionTypes = [ + { id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }, + { id: 'copilot-cloud-agent', label: 'Cloud', icon: Codicon.cloud }, + ]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, ''); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts new file mode 100644 index 0000000000000..5e16da70340cb --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js'; +import { IOutputService } from '../../../../../workbench/services/output/common/output.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { extUri } from '../../../../../base/common/resources.js'; +import { ISessionsProvidersService } from '../../../sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionChangeEvent, ISessionsProvider } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionWorkspace } from '../../../sessions/common/sessionData.js'; +import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js'; + +// ---- Storage key (must match the one in sessionWorkspacePicker.ts) ---------- +const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; + +// ---- Mock providers --------------------------------------------------------- + +function createMockProvider(id: string, opts?: { + connectionStatus?: ISettableObservable; +}): ISessionsProvider { + return { + id, + label: `Provider ${id}`, + icon: Codicon.remote, + sessionTypes: [], + connectionStatus: opts?.connectionStatus, + browseActions: [], + resolveWorkspace: (uri: URI): ISessionWorkspace => ({ + label: uri.path.substring(1) || uri.path, + icon: Codicon.folder, + repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }), + onDidChangeSessions: Event.None, + getSessions: () => [], + createNewSession: () => { throw new Error('Not implemented'); }, + setSessionType: () => { throw new Error('Not implemented'); }, + getSessionTypes: () => [], + renameChat: async () => { }, + setModel: () => { }, + archiveSession: async () => { }, + unarchiveSession: async () => { }, + deleteSession: async () => { }, + deleteChat: async () => { }, + setRead: () => { }, + sendAndCreateChat: async () => { throw new Error('Not implemented'); }, + capabilities: { multipleChatsPerSession: false }, + }; +} + +class MockSessionsProvidersService extends Disposable { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeProviders = this._register(new Emitter()); + readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; + readonly onDidChangeSessions: Event = Event.None; + readonly onDidReplaceSession = Event.None; + + private _providers: ISessionsProvider[] = []; + + setProviders(providers: ISessionsProvider[]): void { + this._providers = providers; + this._onDidChangeProviders.fire(); + } + + getProviders(): ISessionsProvider[] { + return this._providers; + } + + resolveWorkspace(providerId: string, repositoryUri: URI): ISessionWorkspace | undefined { + const provider = this._providers.find(p => p.id === providerId); + return provider?.resolveWorkspace(repositoryUri); + } +} + +// ---- Test helpers ----------------------------------------------------------- + +function seedStorage(storageService: IStorageService, entries: { uri: URI; providerId: string; checked: boolean }[]): void { + const stored = entries.map(e => ({ + uri: e.uri.toJSON(), + providerId: e.providerId, + checked: e.checked, + })); + storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); +} + +function createTestPicker( + disposables: DisposableStore, + providersService: MockSessionsProvidersService, + storageService?: IStorageService, +): WorkspacePicker { + const instantiationService = disposables.add(new TestInstantiationService()); + const storage = storageService ?? disposables.add(new TestStorageService()); + + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } }); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(ISessionsProvidersService, providersService); + instantiationService.stub(ISessionsManagementService, { + activeProviderId: observableValue('activeProviderId', undefined), + }); + instantiationService.stub(IRemoteAgentHostService, {}); + instantiationService.stub(IQuickInputService, {}); + instantiationService.stub(IClipboardService, {}); + instantiationService.stub(IPreferencesService, {}); + instantiationService.stub(IOutputService, {}); + + return disposables.add(instantiationService.createInstance(WorkspacePicker)); +} + +// ---- Assertion helpers ------------------------------------------------------ + +function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void { + assert.strictEqual(picker.selectedProject?.providerId, expectedProviderId, message); +} + +// ---- Tests ------------------------------------------------------------------ + +suite('WorkspacePicker - Connection Status', () => { + + const disposables = new DisposableStore(); + let providersService: MockSessionsProvidersService; + + setup(() => { + providersService = new MockSessionsProvidersService(); + disposables.add(providersService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('restore skips unavailable (disconnected) provider', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Disconnected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + const localProvider = createMockProvider('local-1'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + { uri: URI.file('/local/project'), providerId: 'local-1', checked: false }, + ]); + + providersService.setProviders([remoteProvider, localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + // The checked entry is from a disconnected provider — should fall back to local + assertSelectedProvider(picker, 'local-1'); + }); + + test('restore skips connecting provider', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connecting); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + const localProvider = createMockProvider('local-1'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + { uri: URI.file('/local/project'), providerId: 'local-1', checked: false }, + ]); + + providersService.setProviders([remoteProvider, localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + assertSelectedProvider(picker, 'local-1'); + }); + + test('restore picks connected remote provider', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + assertSelectedProvider(picker, 'remote-1'); + }); + + test('disconnect clears selection from that provider', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + assertSelectedProvider(picker, 'remote-1'); + + // Disconnect + remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); + assertSelectedProvider(picker, undefined, 'Selection should be cleared after disconnect'); + }); + + test('reconnect restores the same workspace', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + assertSelectedProvider(picker, 'remote-1'); + + // Disconnect — clears selection + remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); + assertSelectedProvider(picker, undefined, 'Should clear on disconnect'); + + // Reconnect — should restore + remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + assertSelectedProvider(picker, 'remote-1', 'Should restore after reconnect'); + assert.strictEqual( + picker.selectedProject?.workspace.repositories[0]?.uri.path, + '/remote/project', + 'Should restore the same workspace URI', + ); + }); + + test('disconnect does not auto-select another provider workspace', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + const localProvider = createMockProvider('local-1'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + { uri: URI.file('/local/project'), providerId: 'local-1', checked: false }, + ]); + + providersService.setProviders([remoteProvider, localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + assertSelectedProvider(picker, 'remote-1'); + + // Disconnect remote + remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); + + // Should NOT auto-select local workspace — should remain empty + assertSelectedProvider(picker, undefined, 'Should not auto-select another provider on disconnect'); + }); + + test('checked is globally unique after persist', () => { + const localProvider = createMockProvider('local-1'); + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + { uri: URI.file('/local/project'), providerId: 'local-1', checked: false }, + ]); + + providersService.setProviders([remoteProvider, localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + // Select the local workspace + const localWorkspace: IWorkspaceSelection = { + providerId: 'local-1', + workspace: localProvider.resolveWorkspace(URI.file('/local/project')), + }; + picker.setSelectedWorkspace(localWorkspace, false); + + // Verify storage: only the local entry should be checked + const raw = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + assert.ok(raw, 'Storage should have recent workspaces'); + const stored = JSON.parse(raw!) as { providerId: string; checked: boolean }[]; + const checkedEntries = stored.filter(e => e.checked); + assert.strictEqual(checkedEntries.length, 1, 'Only one entry should be checked'); + assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked'); + }); + + test('onDidSelectWorkspace fires on reconnect restore', () => { + const remoteStatus = observableValue('status', RemoteAgentHostConnectionStatus.Connected); + const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus }); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true }, + ]); + + providersService.setProviders([remoteProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + const selected: IWorkspaceSelection[] = []; + disposables.add(picker.onDidSelectWorkspace(w => selected.push(w))); + + // Disconnect then reconnect + remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); + remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined); + + assert.strictEqual(selected.length, 1, 'onDidSelectWorkspace should fire once on reconnect'); + assert.strictEqual(selected[0].providerId, 'remote-1'); + assert.strictEqual(selected[0].workspace.repositories[0]?.uri.path, '/remote/project', 'Event should carry the correct workspace URI'); + }); + + test('local provider is never treated as unavailable', () => { + const localProvider = createMockProvider('local-1'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [ + { uri: URI.file('/local/project'), providerId: 'local-1', checked: true }, + ]); + + providersService.setProviders([localProvider]); + const picker = createTestPicker(disposables, providersService, storage); + + assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable'); + }); +}); 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..e929eedb43e92 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,81 @@ 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 { IActiveSession, 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 { - return { - repository: opts.repository, - worktree: opts.worktree, - } as IActiveSessionItem; +import { Task } from '../../../../../workbench/contrib/tasks/common/tasks.js'; +import { ITaskService } from '../../../../../workbench/contrib/tasks/common/taskService.js'; +import { IChat, ISession, SessionStatus } from '../../../sessions/common/sessionData.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; + +function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession { + const workspace = opts.repository ? { + label: 'test', + icon: Codicon.folder, + repositories: [{ + uri: opts.repository, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchName: undefined, + baseBranchProtected: undefined, + }], + requiresWorkspaceTrust: false, + } : undefined; + const chat: IChat = { + resource: URI.parse('file:///session'), + createdAt: new Date(), + 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), + isArchived: observableValue('isArchived', false), + isRead: observableValue('isRead', true), + lastTurnEnd: observableValue('lastTurnEnd', undefined), + description: observableValue('description', undefined), + }; + const session: ISession = { + sessionId: 'test:session', + resource: chat.resource, + providerId: 'test', + sessionType: 'background', + icon: Codicon.copilot, + createdAt: chat.createdAt, + workspace: observableValue('workspace', workspace), + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + loading: observableValue('loading', false), + isArchived: chat.isArchived, + isRead: chat.isRead, + lastTurnEnd: chat.lastTurnEnd, + description: chat.description, + gitHubInfo: observableValue('gitHubInfo', undefined), + chats: observableValue('chats', [chat]), + mainChat: chat, + }; + return session; } 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 +98,12 @@ 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 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 +112,17 @@ suite('SessionsConfigurationService', () => { setup(() => { fileContents = new Map(); jsonEdits = []; - createdTerminals = []; - sentCommands = []; - committedFiles = []; + ranTasks = []; + 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 +143,27 @@ 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; }); storageService = store.add(new InMemoryStorageService()); @@ -128,6 +186,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 +200,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 +224,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 +257,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 +266,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 +281,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 --- @@ -215,8 +318,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(jsonEdits.length, 1); assert.deepStrictEqual(jsonEdits[0].values, [{ path: ['tasks', 1, 'inSessions'], value: true }]); - assert.strictEqual(committedFiles.length, 1); - assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); test('addTaskToSessions does nothing when task label not found', async () => { @@ -244,7 +345,36 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(jsonEdits.length, 1); assert.strictEqual(jsonEdits[0].uri.toString(), repoTasksUri.toString()); assert.deepStrictEqual(jsonEdits[0].values, [{ path: ['tasks', 1, 'inSessions'], value: true }]); - 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 --- @@ -256,7 +386,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]; @@ -267,8 +397,6 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[1].label, 'npm run dev'); assert.strictEqual(tasks[1].inSessions, true); - assert.strictEqual(committedFiles.length, 1); - assert.strictEqual(committedFiles[0].fileUris[0].path, '/worktree/.vscode/tasks.json'); }); test('createAndAddTask writes to repository and does not commit when no worktree', async () => { @@ -278,7 +406,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()); @@ -288,146 +416,233 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[1].label, 'npm run dev'); assert.strictEqual(tasks[1].inSessions, true); - 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.runTask(task, session); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace', { runOn: 'worktreeCreated' }); - 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([])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.createAndAddTask('Start Dev Server', 'npm run dev', session, 'workspace'); - assert.strictEqual(createdTerminals.length, 0); - assert.strictEqual(sentCommands.length, 0); + 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 --- + + 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'), + ])); - await service.runTask(task, session); - await service.runTask(task, session); + 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' }, + ], + }]); }); - 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' } + } + }]); + }); - 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, + } + ] + } + ] + }); }); - // --- 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 }); - assert.strictEqual(obs1.get(), 'build'); - assert.strictEqual(obs2.get(), 'test'); + await service.runTask(makeTask('build', 'npm run build'), session); + + 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..97d5c13b414b3 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.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 { 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 { 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'; +import { CopilotCloudSessionType } from '../../sessions/browser/sessionTypes.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: ContextKeyExpr.and( + ChatContextKeys.hasAgentSessionChanges, + canRunSessionCodeReviewContextKey), + menu: [ + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 7, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ChatContextKeys.agentSessionType.notEqualsTo(CopilotCloudSessionType.id), + ), + }, + ], + }); + } + + 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 ISession + 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..c2eb79093ed62 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -0,0 +1,730 @@ +/*--------------------------------------------------------------------------------------------- + * 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 session = this._sessionsManagementService.getSession(sessionResource); + const gitHubInfo = session?.gitHubInfo.get(); + if (gitHubInfo?.pullRequest) { + const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + 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 session = this._sessionsManagementService.getSession(sessionResource); + const gitHubInfo = session?.gitHubInfo.get(); + if (!gitHubInfo?.pullRequest) { + return; + } + + data.initialized = true; + data.state.set({ kind: PRReviewStateKind.Loading }, undefined); + + const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + const workspace = session?.workspace.get(); + + // 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 baseUri = workspace?.repositories[0]?.workingDirectory ?? workspace?.repositories[0]?.uri; + if (!baseUri) { + continue; + } + const fileUri = URI.joinPath(baseUri, thread.path); + 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..7128b5c160475 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -0,0 +1,1033 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISession } from '../../../sessions/common/sessionData.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): ISession | undefined { + return this._sessions.get(resource.toString()); + } + + addSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): ISession { + 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: ISession = { + sessionId: `test:${resource.toString()}`, + resource, + changes: changesObs, + isArchived: isArchivedObs, + gitHubInfo: observableValue('test.gitHubInfo', undefined), + } as unknown as ISession; + 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(): ISession[] { + return [...this._sessions.values()]; + } + + 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..9852a3a9b3f61 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -5,46 +5,68 @@ import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.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', '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.autoCommit.enabled': false, 'github.copilot.chat.cli.branchSupport.enabled': true, - 'github.copilot.chat.languageContext.typescript.enabled': true, + 'github.copilot.chat.cli.isolationOption.enabled': true, '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.panel.showLabels': false, + 'workbench.colorTheme': ThemeSettingDefaults.COLOR_THEME_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..fed92499d7e8a --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider, ICopilotChatSession } from './copilotChatSessionsProvider.js'; + +const FILTER_THRESHOLD = 10; + +interface IBranchItem { + readonly name: string; +} + +/** + * A widget for selecting a git branch. + * Reads branch list and selected branch from the active session, + * which is the source of truth for branch state. + */ +export class BranchPicker extends Disposable { + + private readonly _renderDisposables = this._register(new DisposableStore()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + const providerSession = session ? this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + if (providerSession) { + providerSession.loading.read(reader); + providerSession.branches.read(reader); + providerSession.branch.read(reader); + providerSession.isolationMode.read(reader); + } + this._updateTriggerLabel(); + })); + } + + private _getSession(): ICopilotChatSession | undefined { + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return undefined; + } + return this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId); + } + + 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 { + const session = this._getSession(); + const branches = session?.branches.get() ?? []; + if (!this._triggerElement || this.actionWidgetService.isVisible || branches.length === 0 || session?.isolationMode.get() === 'workspace') { + return; + } + + const selectedBranch = session?.branch.get(); + const items: IActionListItem[] = branches.map(branch => ({ + kind: ActionListItemKind.Action, + label: branch, + group: { title: '', icon: Codicon.gitBranch }, + item: { name: branch, checked: branch === selectedBranch || undefined }, + })); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + session?.setBranch(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 _updateTriggerLabel(): void { + if (!this._triggerElement || !this._slotElement) { + return; + } + dom.clearNode(this._triggerElement); + + const session = this._getSession(); + const branches = session?.branches.get() ?? []; + const isLoading = session?.loading.get() ?? false; + const isDisabled = session?.isolationMode.get() === 'workspace'; + const label = session?.branch.get() ?? 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)); + + const visible = !(isLoading || branches.length === 0); + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', isDisabled); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); + this._triggerElement.tabIndex = visible && !isDisabled ? 0 : -1; + } +} 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..071566df259ef --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * 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, COPILOT_MULTI_CHAT_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [COPILOT_MULTI_CHAT_SETTING]: { + type: 'boolean', + default: false, + tags: ['preview'], + description: localize('sessions.github.copilot.multiChatSessions', "Whether to enable multiple chats within a single session in the Copilot Chat sessions provider."), + }, + }, +}); + +/** + * 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, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + // When the local agent host is enabled, skip registering the + // default CopilotChat provider so only the local agent host + // provider is active. + if (configurationService.getValue(AgentHostEnabledSettingId)) { + return; + } + + 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..7cd93f95902cf --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce } from '../../../../base/common/arrays.js'; +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 { 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 } from '../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.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 { ISession } from '../../sessions/common/sessionData.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; +import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE } from '../../sessions/browser/sessionTypes.js'; +import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.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 IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE); +const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE); +const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, COPILOT_PROVIDER_ID); +const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); +const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider); +const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(ActiveSessionProviderIdContext.key, /^agenthost-/); +const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, 'local-agent-host'); + +// -- 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( + IsNewChatSessionContext, + 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: ContextKeyExpr.and( + IsNewChatSessionContext, + 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: ContextKeyExpr.or(IsActiveSessionCopilotChatCLI, IsActiveSessionRemoteAgentHost, IsActiveSessionLocalAgentHost), + }], + }); + } + 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, + @IStorageService storageService: IStorageService, + ) { + 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); + storageService.store('sessions.localModelPicker.selectedModelId', model.identifier, StorageScope.PROFILE, StorageTarget.MACHINE); + 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, sessionsManagementService), + 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 remembered model or first available model + const rememberedModelId = storageService.get('sessions.localModelPicker.selectedModelId', StorageScope.PROFILE); + const initModel = () => { + const models = getAvailableModels(languageModelsService, sessionsManagementService); + modelPicker.setEnabled(models.length > 0); + if (!currentModel.get() && models.length > 0) { + const remembered = rememberedModelId ? models.find(m => m.identifier === rememberedModelId) : undefined; + delegate.setModel(remembered ?? models[0]); + } + }; + 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, + sessionsManagementService: ISessionsManagementService, +): ILanguageModelChatMetadataAndIdentifier[] { + const session = sessionsManagementService.activeSession.get(); + if (!session) { + return []; + } + 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 === session.sessionType); +} + +// -- Context Key Contribution -- + +class CopilotActiveSessionContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.copilotActiveSession'; + + constructor( + @ISessionsManagementService sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const hasRepositoryKey = ActiveSessionHasGitRepositoryContext.bindTo(contextKeyService); + + this._register(autorun((reader: IReader) => { + const session = sessionsManagementService.activeSession.read(reader); + const providerSession = session ? sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + const isLoading = providerSession?.loading.read(reader); + hasRepositoryKey.set(!isLoading && !!providerSession?.gitRepository); + })); + } +} + +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 ISession} → {@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, context?: ISession | ISession[]) => { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const agentSessions = coalesce(sessions.map(s => this.agentSessionsService.getSession(s.resource))); + if (agentSessions.length === 0) { + return; + } + return this.commandService.executeCommand(commandId, { + session: agentSessions[0], + sessions: agentSessions, + $mid: MarshalledId.AgentSessionContext, + }); + })); + + const providerWhen = ContextKeyExpr.equals(ChatSessionProviderIdContext.key, 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..ce66e5ee1f6d5 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -0,0 +1,2074 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { raceTimeout } from '../../../../base/common/async.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { themeColorFromId, 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 { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo } from '../../sessions/common/sessionData.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { ISendRequestOptions, ISessionsBrowseAction, ISessionChangeEvent, 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, CancellationTokenSource } from '../../../../base/common/cancellation.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'; +import { CopilotCLISessionType, CopilotCloudSessionType } from '../../sessions/browser/sessionTypes.js'; +import { SessionsGroupModel } from '../../sessions/browser/sessionsGroupModel.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; + +export interface ICopilotChatSession { + /** Globally unique session ID (`providerId:localId`). */ + readonly id: 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; + /** GitHub information associated with this session, if any. */ + readonly gitHubInfo: IObservable; + + readonly permissionLevel: IObservable; + setPermissionLevel(level: ChatPermissionLevel): void; + + readonly branch: IObservable; + setBranch(branch: string | undefined): void; + + readonly isolationMode: IObservable; + setIsolationMode(mode: IsolationMode): void; + + setModelId(modelId: string): void; + setMode(chatMode: IChatMode | undefined): void; + + readonly gitRepository?: IGitRepository; + readonly branches: IObservable; +} + +const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; + +/** Provider ID for the Copilot Chat Sessions provider. */ +export const COPILOT_PROVIDER_ID = 'default-copilot'; + +/** Setting key controlling whether the Copilot provider supports multiple chats per session. */ +export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSessions'; + + +const REPOSITORY_OPTION_ID = 'repository'; +const BRANCH_OPTION_ID = 'branch'; +const ISOLATION_OPTION_ID = 'isolation'; +const AGENT_OPTION_ID = 'agent'; + +type NewSession = CopilotCLISession | RemoteNewSession; + +/** + * Local new session for Background agent sessions. + * Implements {@link ICopilotChatSession} (session facade) and provides + * pre-send configuration methods for the new-session flow. + */ +class CopilotCLISession extends Disposable implements ICopilotChatSession { + + static readonly COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; + + // -- ISessionData fields -- + + readonly id: 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 _description: ReturnType>; + readonly description: IObservable; + + 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 branch: IObservable = this._branchObservable; + + private readonly _isolationModeObservable = observableValue(this, 'worktree'); + readonly isolationMode: IObservable = this._isolationModeObservable; + + 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; + + private readonly _changes: ReturnType>; + readonly changes: IObservable; + + readonly isArchived: IObservable = observableValue(this, false); + readonly isRead: IObservable = observableValue(this, true); + readonly lastTurnEnd: IObservable = observableValue(this, undefined); + readonly gitHubInfo: IObservable = observableValue(this, undefined); + + private _gitRepository: IGitRepository | undefined; + private readonly _loadBranchesCts = this._register(new MutableDisposable()); + + // -- Branch state -- + + private readonly _branches = observableValue(this, []); + readonly branches: IObservable = this._branches; + + private _defaultBranch: string | 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 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.id = `${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(); + + this._description = observableValue(this, undefined); + this.description = this._description; + + this._changes = observableValue(this, []); + this.changes = this._changes; + } + + private async _resolveGitRepository(): Promise { + const repoUri = this.sessionWorkspace.repositories[0]?.uri; + if (repoUri) { + try { + this._gitRepository = await this.gitService.openRepository(repoUri); + if (!this._gitRepository) { + this.setIsolationMode('workspace'); + } + } catch { + // No git repository available + this.setIsolationMode('workspace'); + } + } + if (this._gitRepository) { + this._loadBranches(this._gitRepository); + + // Automatically update the selected branch when the repository + // state changes. This is done only for the Folder sessions. + const currentBranchName = derived(reader => { + const state = this._gitRepository?.state.read(reader); + return state?.HEAD?.name; + }); + + this._register(autorun(reader => { + const isolationMode = this.isolationMode.read(reader); + if (isolationMode === 'worktree') { + return; + } + + const currentBranch = currentBranchName.read(reader); + this.setBranch(currentBranch ?? this._defaultBranch); + })); + } + this._loading.set(false, undefined); + } + + private _loadBranches(repo: IGitRepository): void { + this._loadBranchesCts.value?.cancel(); + const cts = this._loadBranchesCts.value = new CancellationTokenSource(); + + repo.getRefs({ pattern: 'refs/heads' }, cts.token).then(refs => { + if (cts.token.isCancellationRequested) { + return; + } + const branches = refs + .map(r => r.name) + .filter((name): name is string => !!name) + .filter(name => !name.includes(CopilotCLISession.COPILOT_WORKTREE_PATTERN)); + + const defaultBranch = branches.find(b => b === 'main') + ?? branches.find(b => b === 'master') + ?? branches.find(b => b === repo.state.get().HEAD?.name) + ?? branches[0]; + + this._defaultBranch = defaultBranch; + + transaction(tx => { + this._branches.set(branches, tx); + }); + + if (defaultBranch && !this._branch) { + this.setBranch(defaultBranch); + } + }).catch(() => { + if (!cts.token.isCancellationRequested) { + transaction(tx => { + this._branches.set([], tx); + }); + } + }); + } + + setIsolationMode(mode: IsolationMode): void { + if (this._isolationMode !== mode) { + this._isolationMode = mode; + this._isolationModeObservable.set(mode, undefined); + this.setOption(ISOLATION_OPTION_ID, mode); + + if (mode === 'workspace') { + // When switching to workspace mode, update the branch + // selection to reflect the current branch as that is + // what will be used for the folder session + const currentBranch = this._gitRepository?.state.get().HEAD?.name; + this.setBranch(currentBranch ?? this._defaultBranch); + } else { + this.setBranch(this._defaultBranch); + } + } + } + + setBranch(branch: string | undefined): void { + if (this._branch !== branch) { + this._branch = branch; + this._branchObservable.set(branch, undefined); + 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); + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, 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); + } + + update(agentSession: IAgentSession): void { + const session = new AgentSessionAdapter(agentSession, this.providerId); + this._workspaceData.set(session.workspace.get(), undefined); + this._title.set(session.title.get(), undefined); + this._status.set(session.status.get(), undefined); + this._updatedAt.set(session.updatedAt.get(), undefined); + this._changes.set(session.changes.get(), undefined); + this._description.set(session.description.get(), undefined); + } +} + +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 ICopilotChatSession} (session facade) and provides + * pre-send configuration methods for the new-session flow. + */ +export class RemoteNewSession extends Disposable implements ICopilotChatSession { + + // -- ISessionData fields -- + + readonly id: 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 = constObservable(undefined); + readonly lastTurnEnd: IObservable = constObservable(undefined); + readonly gitHubInfo: IObservable = constObservable(undefined); + readonly branch: IObservable = constObservable(undefined); + readonly isolationMode: IObservable = constObservable(undefined); + readonly branches: IObservable = constObservable([]); + readonly gitRepository?: IGitRepository | 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 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.id = `${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 }); + } + + } + setPermissionLevel(level: ChatPermissionLevel): void { + throw new Error('Method not implemented.'); + } + + // -- 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; + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } + + 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]; + } + + update(_session: IAgentSession): void { } +} + +/** + * 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 ICopilotChatSession} facade. + */ +class AgentSessionAdapter implements ICopilotChatSession { + + readonly id: 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 _gitHubInfo: ReturnType>; + readonly gitHubInfo: IObservable; + + readonly permissionLevel: IObservable = constObservable(ChatPermissionLevel.Default); + readonly branch: IObservable = constObservable(undefined); + readonly isolationMode: IObservable = constObservable(undefined); + readonly gitRepository?: IGitRepository | undefined; + readonly branches: IObservable = constObservable([]); + + constructor( + session: IAgentSession, + providerId: string, + ) { + this.id = `${providerId}:${session.resource.toString()}`; + this.resource = session.resource; + this.providerId = providerId; + this.sessionType = session.providerType; + this.icon = this._getSessionTypeIcon(session); + 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._gitHubInfo = observableValue(this, this._extractGitHubInfo(session)); + this.gitHubInfo = this._gitHubInfo; + } + + setPermissionLevel(level: ChatPermissionLevel): void { + throw new Error('Method not implemented.'); + } + setBranch(branch: string | undefined): void { + throw new Error('Method not implemented.'); + } + setIsolationMode(mode: IsolationMode): void { + throw new Error('Method not implemented.'); + } + setModelId(modelId: string): void { + throw new Error('Method not implemented.'); + } + setMode(chatMode: IChatMode | undefined): void { + throw new Error('Method not implemented.'); + } + + /** + * 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._gitHubInfo.set(this._extractGitHubInfo(session), tx); + }); + } + + private _getSessionTypeIcon(session: IAgentSession): ThemeIcon { + switch (session.providerType) { + case AgentSessionProviders.Background: + return CopilotCLISessionType.icon; + case AgentSessionProviders.Cloud: + return CopilotCloudSessionType.icon; + default: + return session.icon; + } + } + + private _extractDescription(session: IAgentSession): IMarkdownString | undefined { + if (!session.description) { + return undefined; + } + return typeof session.description === 'string' ? new MarkdownString(session.description) : session.description; + } + + private _extractGitHubInfo(session: IAgentSession): IGitHubInfo | undefined { + const metadata = session.metadata; + if (!metadata) { + return undefined; + } + + const { owner, repo } = this._extractOwnerRepo(session); + if (!owner || !repo) { + return undefined; + } + + const pullRequestUri = this._extractPullRequestUri(session); + if (!pullRequestUri) { + return { owner, repo }; + } + + const prNumber = this._extractPullRequestNumber(session, pullRequestUri); + if (prNumber === undefined) { + return { owner, repo }; + } + + return { owner, repo, pullRequest: { number: prNumber, uri: pullRequestUri, icon: this._extractPullRequestStateIcon(session) } }; + } + + private _extractPullRequestNumber(session: IAgentSession, pullRequestUri: URI): number | undefined { + const metadata = session.metadata; + if (typeof metadata?.pullRequestNumber === 'number') { + return metadata.pullRequestNumber as number; + } + const match = /\/pull\/(\d+)/.exec(pullRequestUri.path); + if (match) { + return parseInt(match[1], 10); + } + return undefined; + } + + private _extractOwnerRepo(session: IAgentSession): { owner: string | undefined; repo: string | undefined } { + const metadata = session.metadata; + if (!metadata) { + return { owner: undefined, repo: undefined }; + } + + // Direct owner + name fields + if (typeof metadata.owner === 'string' && typeof metadata.name === 'string') { + return { owner: metadata.owner, repo: metadata.name }; + } + + // repositoryNwo: "owner/repo" + if (typeof metadata.repositoryNwo === 'string') { + const parts = (metadata.repositoryNwo as string).split('/'); + if (parts.length === 2) { + return { owner: parts[0], repo: parts[1] }; + } + } + + // Parse from workspace repository URI (cloud sessions) + const repoUri = this._buildWorkspace(session)?.repositories[0]?.uri; + if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = repoUri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + return { owner: decodeURIComponent(parts[0]), repo: decodeURIComponent(parts[1]) }; + } + } + + // Parse from pullRequestUrl + 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] }; + } + } + + return { owner: undefined, repo: undefined }; + } + + private _extractPullRequestStateIcon(session: IAgentSession): ThemeIcon | undefined { + const metadata = session.metadata; + const state = metadata?.pullRequestState; + if (state) { + switch (state) { + case 'merged': + return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; + case 'closed': + return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; + case 'draft': + return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; + default: + return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; + } + } + return undefined; + } + + 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 { + const [repoUri, worktreeUri, branchName, baseBranchName, baseBranchProtected] = this._extractRepositoryFromMetadata(session); + + const repository: ISessionRepository = { + uri: repoUri ?? URI.parse('unknown://'), + workingDirectory: worktreeUri, + detail: branchName, + baseBranchName, + baseBranchProtected, + }; + + return { + label: getRepositoryName(session) ?? basename(repository.uri), + icon: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? Codicon.repo : Codicon.folder, + repositories: [repository], + requiresWorkspaceTrust: session.providerType !== AgentSessionProviders.Cloud, + }; + } + + /** + * Extract repository/worktree information from session metadata. + * Mirrors the logic in sessionsManagementService.getRepositoryFromMetadata(). + */ + private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, string | undefined, boolean | undefined] { + const metadata = session.metadata; + if (!metadata) { + return [undefined, 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, undefined]; + } + + // Background/CLI sessions: check workingDirectoryPath first + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; + if (workingDirectoryPath) { + return [URI.file(workingDirectoryPath), undefined, 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 worktreeBaseBranchName = metadata?.baseBranchName as string | undefined; + const worktreeBaseBranchProtected = metadata?.baseBranchProtected as boolean | undefined; + + return [ + URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, + URI.isUri(worktreePathUri) ? worktreePathUri : undefined, + worktreeBranchName, + worktreeBaseBranchName, + 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]; + + get capabilities() { + return { + multipleChatsPerSession: this._isMultiChatEnabled(), + }; + } + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + + /** Cache of adapted sessions, keyed by resource URI string. */ + private readonly _sessionCache = new Map(); + + /** Cache of ISession wrappers, keyed by session group ID. */ + private readonly _sessionGroupCache = new Map(); + + /** Group model tracking which chats belong to which session. */ + private readonly _groupModel: SessionsGroupModel; + + 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, + @IStorageService storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + + this._groupModel = this._register(new SessionsGroupModel(storageService)); + + this.browseActions = [ + { + label: localize('folders', "Folders"), + icon: Codicon.folderOpened, + providerId: this.id, + execute: () => this._browseForFolder(), + }, + { + label: localize('repositories', "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(); + })); + } + + // -- Sessions -- + + getSessionTypes(sessionId: string): ISessionType[] { + const session = this._currentNewSession?.id === sessionId ? this._currentNewSession : this._findChatSession(sessionId); + if (!session) { + return []; + } + if (session instanceof CopilotCLISession) { + return [CopilotCLISessionType]; + } + if (session instanceof RemoteNewSession) { + return [CopilotCloudSessionType]; + } + return []; + } + + getSessions(): ISession[] { + this._ensureSessionCache(); + + if (!this._isMultiChatEnabled()) { + return Array.from(this._sessionCache.values()).map(chat => this._chatToSession(chat)); + } + + const allChats = Array.from(this._sessionCache.values()); + + // Group chats using the group model + const seen = new Set(); + const sessions: ISession[] = []; + + for (const chat of allChats) { + const groupId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; + if (!seen.has(groupId)) { + seen.add(groupId); + sessions.push(this._chatToSession(chat)); + } + } + return sessions; + } + + // -- Session Lifecycle -- + + private _currentNewSession: NewSession | undefined; + + getSession(sessionId: string): ICopilotChatSession | undefined { + if (this._currentNewSession?.id === sessionId) { + return this._currentNewSession; + } + return this._findChatSession(sessionId); + } + + createNewSession(workspace: ISessionWorkspace): ISession { + 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 this._chatToSession(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 this._chatToSession(session); + } + + setSessionType(sessionId: string, type: ISessionType): ISession { + throw new Error('Session type cannot be changed'); + } + + setModel(sessionId: string, modelId: string): void { + if (this._currentNewSession?.id === sessionId) { + this._currentNewSession.setModelId(modelId); + } + } + + // -- Session Actions -- + + async archiveSession(sessionId: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setArchived(true); + return; + } + + // Temp session that hasn't been committed — remove it directly + this._cleanupTempSession(sessionId); + } + + async unarchiveSession(sessionId: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setArchived(false); + } + } + + async deleteSession(sessionId: string): Promise { + // Collect all chat IDs in this session group + const chatIds = this._isMultiChatEnabled() + ? this._groupModel.getChatIds(sessionId) + : []; + + // Delete the primary session + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + if (agentSession.providerType === CopilotCLISessionType.id) { + this.commandService.executeCommand('github.copilot.cli.sessions.delete', { resource: agentSession.resource }); + } else { + await this.chatService.removeHistoryEntry(agentSession.resource); + } + + // Delete all other chats in the group + for (const chatId of chatIds) { + if (chatId === sessionId) { + continue; // Already deleted above + } + const chatSession = this._findAgentSession(chatId); + if (chatSession) { + if (chatSession.providerType === CopilotCLISessionType.id) { + this.commandService.executeCommand('github.copilot.cli.sessions.delete', { resource: chatSession.resource }); + } else { + await this.chatService.removeHistoryEntry(chatSession.resource); + } + } + } + + // Clean up group model + if (this._isMultiChatEnabled()) { + this._groupModel.deleteSession(sessionId); + this._sessionGroupCache.delete(sessionId); + } + + this._refreshSessionCache(); + return; + } + + // Temp session that hasn't been committed — remove it directly + this._cleanupTempSession(sessionId); + } + + async renameChat(sessionId: string, _chatUri: URI, title: string): Promise { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + if (agentSession.providerType === CopilotCLISessionType.id) { + this.commandService.executeCommand('github.copilot.cli.sessions.setTitle', { resource: agentSession.resource }, title); + } else { + this.chatService.setChatSessionTitle(agentSession.resource, title); + } + } + } + + async deleteChat(sessionId: string, chatUri: URI): Promise { + if (!this._isMultiChatEnabled()) { + throw new Error('Deleting individual chats is not supported when multi-chat is disabled'); + } + + const chatIds = this._groupModel.getChatIds(sessionId); + if (chatIds.length <= 1) { + // Only one chat — delete the entire session + return this.deleteSession(sessionId); + } + + // Find the chat matching the URI + const chatId = chatIds.find(id => { + const chat = this._sessionCache.get(this._localIdFromchatId(id)); + return chat && chat.resource.toString() === chatUri.toString(); + }); + if (!chatId) { + return; + } + + // Delete the underlying agent session first. + // _refreshSessionCacheMultiChat handles the removed chat gracefully: + // it detects the chat belongs to a group with remaining siblings and + // fires a changed event on the parent session instead of a removed event. + const agentSession = this._findAgentSession(chatId); + if (agentSession) { + if (agentSession.providerType === CopilotCLISessionType.id) { + this.commandService.executeCommand('github.copilot.cli.sessions.delete', { resource: agentSession.resource }); + } else { + await this.chatService.removeHistoryEntry(agentSession.resource); + } + } + } + + setRead(sessionId: string, read: boolean): void { + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setRead(read); + } + } + + // -- Send -- + + async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise { + // Determine if this is the first chat or a subsequent chat + const session = this._currentNewSession; + if (session && session.id === sessionId) { + // First chat — use the existing new-session flow + return this._sendFirstChat(session, options); + } + + if (!this._isMultiChatEnabled()) { + throw new Error(`Session '${sessionId}' not found or not a new session`); + } + + // Subsequent chat — create a new chat within the existing session + return this._sendSubsequentChat(sessionId, options); + } + + /** + * Sends the first chat for a newly created session. + * Adds the temp session to the cache, waits for commit, then replaces it. + */ + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession, options: ISendRequestOptions): Promise { + + 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) { + this.chatSessionsService.updateSessionOptions(session.resource, session.selectedOptions); + } + modelRef.dispose(); + } + + // Send request + const result = await this.chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); + } + + // Extract promises to detect cancellation vs normal completion + const responseCompletePromise = result.kind === 'sent' + ? result.data.responseCompletePromise + : undefined; + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; + + // Add the new session to the sessions model immediately so it appears in the sessions list + session.setTitle(localize('new session', "New Session")); + session.setStatus(SessionStatus.InProgress); + const key = session.resource.toString(); + this._sessionCache.set(key, session); + const newSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); + + try { + + // Wait for the session to be committed (URI swapped from untitled to real) + const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise); + + // Wait for _refreshSessionCache to populate the committed adapter + const committedChat = await this._waitForSessionInCache(committedResource); + + // Remove the temp from the cache (the adapter now owns the committed key) + this._sessionCache.delete(key); + this._currentNewSession = undefined; + session.dispose(); + + // Register the committed chat in the group model + this._groupModel.addChat(committedChat.id, committedChat.id); + + const committedSession = this._chatToSession(committedChat); + + // Notify listeners that the temp session was replaced by the committed one + this._sessionGroupCache.delete(session.id); + this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); + + return committedSession; + } catch (error) { + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Session was stopped before the agent created a worktree. + // Keep the temp session in the list so the user can review + // whatever content the agent produced before cancellation. + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + return newSession; + } + + // Unexpected error — clean up the temp session entirely + this._sessionCache.delete(key); + this._sessionGroupCache.delete(session.id); + this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); + session.dispose(); + throw error; + } + } + + /** + * Sends a subsequent chat for an existing session that already has chats. + * Creates a new {@link CopilotCLISession} from the existing workspace, + * registers it in the group model, and fires a `changed` event (not `added`). + */ + private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise { + const newChatSession = this._createNewSessionFrom(sessionId); + + // Add the temp session to the cache and group model immediately + // so the chats observable picks it up and tabs appear right away. + newChatSession.setTitle(localize('new chat', "New Chat")); + newChatSession.setStatus(SessionStatus.InProgress); + const key = newChatSession.resource.toString(); + this._sessionCache.set(key, newChatSession); + this._groupModel.addChat(sessionId, newChatSession.id); + + // Invalidate the session group cache so it rebuilds with the new chat + this._sessionGroupCache.delete(sessionId); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] }); + + const { query, attachedContext } = options; + + const contribution = this.chatSessionsService.getChatSessionContribution(newChatSession.target); + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: newChatSession.selectedModelId, + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: true, + modeInstructions: undefined, + modeId: 'agent', + applyCodeBlockSuggestionId: undefined, + permissionLevel: newChatSession.permissionLevel.get(), + }, + agentIdSilent: contribution?.type, + attachedContext, + }; + + // Open chat widget + await this.chatSessionsService.getOrCreateChatSession(newChatSession.resource, CancellationToken.None); + const chatWidget = await this.chatWidgetService.openSession(newChatSession.resource, ChatViewPaneTarget); + if (!chatWidget) { + this._sessionCache.delete(key); + this._groupModel.removeChat(newChatSession.id); + throw new Error('[DefaultCopilotProvider] Failed to open chat widget for subsequent chat'); + } + + // Send request + const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions); + if (result.kind === 'rejected') { + this._sessionCache.delete(key); + this._groupModel.removeChat(newChatSession.id); + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); + } + + // Extract promises to detect cancellation vs normal completion + const responseCompletePromise = result.kind === 'sent' + ? result.data.responseCompletePromise + : undefined; + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; + + try { + // Wait for the session to be committed + const committedResource = await this._waitForCommittedSession(newChatSession.resource, responseCompletePromise, responseCreatedPromise); + + const committedChat = await this._waitForSessionInCache(committedResource); + + // Clean up temp + this._sessionCache.delete(key); + this._currentNewSession = undefined; + newChatSession.dispose(); + + // Update group model: replace temp ID with committed ID + this._groupModel.removeChat(newChatSession.id); + if (this._groupModel.hasGroupForSession(committedChat.id)) { + this._groupModel.deleteSession(committedChat.id); + } + this._groupModel.addChat(sessionId, committedChat.id); + + // Invalidate the session group cache so it rebuilds with the new chat + this._sessionGroupCache.delete(sessionId); + const updatedSession = this._chatToSession(committedChat); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + + return updatedSession; + } catch (error) { + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Cancelled before commit — keep the chat in the group so the + // user can review the content the agent produced. + newChatSession.setStatus(SessionStatus.Completed); + this._sessionGroupCache.delete(sessionId); + const updatedSession = this._chatToSession(newChatSession); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] }); + return updatedSession; + } + + // Unexpected error — clean up on error, fire changed on the parent session group + this._sessionCache.delete(key); + this._groupModel.removeChat(newChatSession.id); + this._sessionGroupCache.delete(sessionId); + newChatSession.dispose(); + // Find the parent session's primary chat to fire a valid changed event + const parentChatIds = this._groupModel.getChatIds(sessionId); + const parentChatId = parentChatIds[0]; + const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined; + if (parentChat) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(parentChat)] }); + } + throw error; + } + } + + /** + * Creates a new {@link CopilotCLISession} from an existing session's workspace. + * Used for subsequent chats that share the same workspace but are independent conversations. + */ + private _createNewSessionFrom(sessionId: string): CopilotCLISession { + // Find the primary chat for this session + const chatIds = this._groupModel.getChatIds(sessionId); + const firstChatId = chatIds[0] ?? sessionId; + const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId)); + if (!chat) { + throw new Error(`Session '${sessionId}' not found`); + } + + if (chat.sessionType === AgentSessionProviders.Cloud) { + throw new Error('Multiple chats per session is not supported for cloud sessions'); + } + + const workspace = chat.workspace.get(); + if (!workspace) { + throw new Error('Chat session has no associated workspace'); + } + + const repository = workspace.repositories[0]; + if (!repository) { + throw new Error('Workspace has no repository'); + } + + if (this._currentNewSession) { + this._currentNewSession.dispose(); + this._currentNewSession = undefined; + } + + const newWorkspace: ISessionWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri); + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id); + session.setIsolationMode('workspace'); + this._currentNewSession = session; + return session; + } + + /** + * Waits for the committed (real) URI for a session by listening to the + * {@link IChatSessionsService.onDidCommitSession} event. + * + * When {@link responseCompletePromise} is provided, the wait is bounded by + * response completion. If the response finishes before the commit event, + * the commit may still be in-flight (e.g. the user cancelled after the + * worktree was initiated but before the commit IPC finished, or the + * extension fired the commit mid-turn but it hasn't been delivered yet). + * In both cases we wait with the safety timeout. Only if the timeout + * expires *and* the response was cancelled do we throw a + * {@link CancellationError} — signalling that the commit will never come. + */ + private async _waitForCommittedSession( + untitledResource: URI, + responseCompletePromise?: Promise, + responseCreatedPromise?: Promise, + ): Promise { + const disposables = new DisposableStore(); + try { + const commitPromise = new Promise(resolve => { + disposables.add(this.chatSessionsService.onDidCommitSession(e => { + if (isEqual(e.original, untitledResource)) { + resolve(e.committed); + } + })); + }); + + if (responseCompletePromise) { + // Race the commit event against the response completing. + const committed = await Promise.race([ + commitPromise.then(uri => ({ committed: true as const, uri })), + responseCompletePromise.then(() => ({ committed: false as const })), + ]); + + if (committed.committed) { + return committed.uri; + } + + // Response finished before the commit event arrived. + // The commit may still be in-flight — the agent could have + // initiated the worktree before the user cancelled, and the + // async IPC chain hasn't delivered the event yet. Fall through + // to the safety timeout to give it a chance to arrive. + } + + const result = await raceTimeout(commitPromise, 5_000); + if (!result) { + // Timed out — check whether this was a cancellation + const response = responseCreatedPromise ? await responseCreatedPromise : undefined; + if (response?.isCanceled) { + throw new CancellationError(); + } + throw new Error('Timed out waiting for session commit'); + } + return result; + } finally { + disposables.dispose(); + } + } + + /** + * Waits for an {@link AgentSessionAdapter} with the given resource to appear + * in the session cache (populated by {@link _refreshSessionCache}). + * Only called once during session initialisation (after the commit event), + * so the timeout has no performance impact on steady-state operations. + */ + private async _waitForSessionInCache(resource: URI): Promise { + const key = resource.toString(); + const existing = this._sessionCache.get(key); + if (existing instanceof AgentSessionAdapter) { + return existing; + } + + const disposables = new DisposableStore(); + try { + const sessionPromise = new Promise(resolve => { + disposables.add(this.onDidChangeSessions(e => { + const cached = this._sessionCache.get(key); + if (cached instanceof AgentSessionAdapter) { + resolve(cached); + } + })); + }); + + // The adapter should appear almost immediately after the commit + // event via _refreshSessionCache; use a short safety timeout. + const result = await raceTimeout(sessionPromise, 5_000); + if (!result) { + throw new Error('Timed out waiting for committed session in cache'); + } + return result; + } finally { + disposables.dispose(); + } + } + + // -- 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, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true + }; + } + 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, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }; + } + return undefined; + } + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + return { + label: this._labelFromUri(repositoryUri), + icon: this._iconFromUri(repositoryUri), + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME + }; + } + + 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 _ensureSessionCache(): void { + if (this._sessionCache.size > 0) { + return; + } + this._refreshSessionCache(); + } + + /** + * Cleans up a temp session (one that hasn't been committed) from the cache. + * Used when delete/archive is invoked on a session that is still pending + * commit (e.g. was stopped before the agent created a worktree). + */ + private _cleanupTempSession(sessionId: string): void { + const chatSession = this._findChatSession(sessionId); + if (!chatSession) { + return; + } + + const key = chatSession.resource.toString(); + this._sessionCache.delete(key); + this._sessionGroupCache.delete(chatSession.id); + if (this._currentNewSession?.id === chatSession.id) { + this._currentNewSession = undefined; + } + const removedSession = this._chatToSession(chatSession); + this._sessionGroupCache.delete(chatSession.id); + this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); + if (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession) { + chatSession.dispose(); + } + } + + private _refreshSessionCache(): void { + const currentKeys = new Set(); + const addedData: ICopilotChatSession[] = []; + const changedData: ICopilotChatSession[] = []; + + for (const session of this.agentSessionsService.model.sessions) { + if (session.providerType !== AgentSessionProviders.Background + && session.providerType !== AgentSessionProviders.Cloud) { + continue; + } + + const key = session.resource.toString(); + currentKeys.add(key); + + const existing = this._sessionCache.get(key); + if (existing) { + existing.update(session); + changedData.push(existing); + } else { + const adapter = new AgentSessionAdapter(session, this.id); + this._sessionCache.set(key, adapter); + addedData.push(adapter); + } + } + + const removedData: ICopilotChatSession[] = []; + for (const [key, adapter] of this._sessionCache) { + if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) { + this._sessionCache.delete(key); + removedData.push(adapter); + } + } + + if (addedData.length > 0 || removedData.length > 0 || changedData.length > 0) { + if (this._isMultiChatEnabled()) { + this._refreshSessionCacheMultiChat(addedData, removedData, changedData); + } else { + this._onDidChangeSessions.fire({ + added: addedData.map(d => this._chatToSession(d)), + removed: removedData.map(d => this._chatToSession(d)), + changed: changedData.map(d => this._chatToSession(d)), + }); + } + } + } + + private _refreshSessionCacheMultiChat( + addedData: ICopilotChatSession[], + removedData: ICopilotChatSession[], + changedData: ICopilotChatSession[], + ): void { + // Track session group IDs for removed chats before modifying the group model + const removedGroupIds = new Map(); + for (const removed of removedData) { + removedGroupIds.set(removed, this._groupModel.getSessionIdForChat(removed.id)); + } + + // Handle removed chats: if a removed chat belongs to a group with + // remaining siblings, treat it as a changed event on the parent session + // instead of a removed session. + const trulyRemovedSessions: { chat: ICopilotChatSession; groupId: string }[] = []; + const changedSessionIds = new Set(); + for (const removed of removedData) { + const sessionId = removedGroupIds.get(removed); + this._groupModel.removeChat(removed.id); + if (sessionId && this._groupModel.getChatIds(sessionId).length > 0) { + // Group still has other chats — invalidate cache and treat as changed + this._sessionGroupCache.delete(sessionId); + if (!changedSessionIds.has(sessionId)) { + changedSessionIds.add(sessionId); + const primaryChatId = this._groupModel.getChatIds(sessionId)[0]; + const primaryChat = this._sessionCache.get(this._localIdFromchatId(primaryChatId)); + if (primaryChat) { + changedData.push(primaryChat); + } + } + } else { + const groupId = sessionId ?? removed.id; + this._sessionGroupCache.delete(groupId); + trulyRemovedSessions.push({ chat: removed, groupId }); + } + } + + // Seed ungrouped chats into the group model + for (const added of addedData) { + if (!this._groupModel.getSessionIdForChat(added.id)) { + this._groupModel.addChat(added.id, added.id); + } + } + + // Separate truly new sessions from chats added to existing groups + const newSessions: ICopilotChatSession[] = []; + for (const added of addedData) { + const existingGroupId = this._groupModel.getSessionIdForChat(added.id); + if (existingGroupId && existingGroupId !== added.id) { + // This chat belongs to an existing session group — treat as changed + if (!changedSessionIds.has(existingGroupId)) { + changedSessionIds.add(existingGroupId); + changedData.push(added); + } + } else { + newSessions.push(added); + } + } + + // Deduplicate changed sessions by group ID + const seenChanged = new Set(); + const deduplicatedChanged: ICopilotChatSession[] = []; + for (const d of changedData) { + const groupId = this._groupModel.getSessionIdForChat(d.id) ?? d.id; + if (!seenChanged.has(groupId)) { + seenChanged.add(groupId); + deduplicatedChanged.push(d); + } + } + + this._onDidChangeSessions.fire({ + added: newSessions.map(d => this._chatToSession(d)), + removed: trulyRemovedSessions.map(({ chat, groupId }) => { + const session = this._sessionGroupCache.get(groupId); + this._sessionGroupCache.delete(groupId); + return session ?? this._chatToSession(chat); + }), + changed: deduplicatedChanged.map(d => this._chatToSession(d)), + }); + } + + private _findChatSession(chatId: string): ICopilotChatSession | undefined { + return this._sessionCache.get(this._localIdFromchatId(chatId)); + } + + private _findAgentSession(chatId: string): IAgentSession | undefined { + const adapter = this._findChatSession(chatId); + if (!adapter) { + return undefined; + } + return this.agentSessionsService.getSession(adapter.resource); + } + + private _localIdFromchatId(chatId: string): string { + const prefix = `${this.id}:`; + return chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; + } + + /** + * Wraps a primary {@link ICopilotChatSession} and its sibling chats into an {@link ISession}. + * When multi-chat is enabled, the `chats` observable is derived from the group model + * and updates automatically when the group model fires a change event. + * When disabled, each session has exactly one chat. + */ + private _chatToSession(chat: ICopilotChatSession): ISession { + if (!this._isMultiChatEnabled()) { + return this._chatToSingleChatSession(chat); + } + + const sessionId = this._groupModel.getSessionIdForChat(chat.id) ?? chat.id; + + const cached = this._sessionGroupCache.get(sessionId); + if (cached) { + return cached; + } + + // Resolve the main (first) chat in the group — session-level properties come from it + const mainChatIds = this._groupModel.getChatIds(sessionId); + const firstChatId = mainChatIds[0]; + const primaryChat = firstChatId + ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat + : chat; + + const chatsObs = observableFromEvent( + this, + Event.filter(this._groupModel.onDidChange, e => e.sessionId === sessionId), + () => { + const chatIds = this._groupModel.getChatIds(sessionId); + if (chatIds.length === 0) { + return [this._toChat(chat)]; + } + const allChats: ICopilotChatSession[] = Array.from(this._sessionCache.values()); + const chatById = new Map(allChats.map(c => [c.id, c])); + const chatOrder = new Map(chatIds.map((id, index) => [id, index])); + const resolved = chatIds.map(id => chatById.get(id)).filter((c): c is ICopilotChatSession => !!c); + if (resolved.length === 0) { + return [this._toChat(chat)]; + } + return resolved + .sort((a, b) => (chatOrder.get(a.id) ?? Infinity) - (chatOrder.get(b.id) ?? Infinity)) + .map(c => this._toChat(c)); + }, + ); + + const mainChat = this._toChat(primaryChat); + const session: ISession = { + sessionId, + resource: primaryChat.resource, + providerId: primaryChat.providerId, + sessionType: primaryChat.sessionType, + icon: primaryChat.icon, + createdAt: primaryChat.createdAt, + workspace: primaryChat.workspace, + title: primaryChat.title, + updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!), + status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)), + changes: primaryChat.changes, + modelId: primaryChat.modelId, + mode: primaryChat.mode, + loading: primaryChat.loading, + isArchived: primaryChat.isArchived, + isRead: chatsObs.map((chats, reader) => chats.every(c => c.isRead.read(reader))), + description: primaryChat.description, + lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))), + gitHubInfo: primaryChat.gitHubInfo, + chats: chatsObs, + mainChat, + }; + this._sessionGroupCache.set(sessionId, session); + return session; + } + + private _chatToSingleChatSession(chat: ICopilotChatSession): ISession { + const mainChat = this._toChat(chat); + return { + sessionId: chat.id, + resource: chat.resource, + providerId: chat.providerId, + sessionType: chat.sessionType, + icon: chat.icon, + createdAt: chat.createdAt, + workspace: chat.workspace, + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + loading: chat.loading, + isArchived: chat.isArchived, + isRead: chat.isRead, + description: chat.description, + lastTurnEnd: chat.lastTurnEnd, + gitHubInfo: chat.gitHubInfo, + chats: constObservable([mainChat]), + mainChat, + }; + } + + private _toChat(chat: ICopilotChatSession): IChat { + return { + resource: chat.resource, + createdAt: chat.createdAt, + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + isArchived: chat.isArchived, + isRead: chat.isRead, + description: chat.description, + lastTurnEnd: chat.lastTurnEnd, + }; + } + + private _latestDate(chats: readonly IChat[], getter: (chat: IChat) => Date | undefined): Date | undefined { + let latest: Date | undefined; + for (const chat of chats) { + const d = getter(chat); + if (d && (!latest || d > latest)) { + latest = d; + } + } + return latest; + } + + private _aggregateStatus(chats: readonly IChat[], reader: IReader): SessionStatus { + for (const c of chats) { + if (c.status.read(reader) === SessionStatus.NeedsInput) { + return SessionStatus.NeedsInput; + } + } + for (const c of chats) { + if (c.status.read(reader) === SessionStatus.InProgress) { + return SessionStatus.InProgress; + } + } + return chats[0].status.read(reader); + } + + private _isMultiChatEnabled(): boolean { + return this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? false; + } +} 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..474ce3da58995 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider } 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 _hasGitRepo = false; + private _isolationOptionEnabled: boolean; + + 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, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + 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._setModeOnSession('worktree'); + } + this._updateTriggerLabel(); + } + })); + + this._register(autorun(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + const isLoading = session?.loading.read(reader); + const providerSession = session ? this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + if (providerSession) { + this._hasGitRepo = !isLoading && !!providerSession.gitRepository; + // Read isolation mode from session — session is the source of truth + providerSession.isolationMode.read(reader); + } else { + this._hasGitRepo = false; + } + this._updateTriggerLabel(); + })); + } + + private _getSessionIsolationMode(): IsolationMode { + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + return providerSession?.isolationMode.get() ?? 'worktree'; + } + + 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._setModeOnSession(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 _setModeOnSession(mode: IsolationMode): void { + const session = this.sessionsManagementService.activeSession.get(); + const providerSession = session ? this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + providerSession?.setIsolationMode(mode); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement || !this._slotElement) { + return; + } + + dom.clearNode(this._triggerElement); + + const isolationMode = this._getSessionIsolationMode(); + let modeIcon; + let modeLabel: string; + + switch (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 visible = this._isolationOptionEnabled && this._hasGitRepo; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; + } +} 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..49cf7550029e6 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { CopilotCLISessionType } from '../../sessions/browser/sessionTypes.js'; +import { CopilotChatSessionsProvider } 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, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + 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(CopilotCLISessionType.id); + 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) { + return; + } + + this.sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId)?.setMode(mode); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement || !this._slotElement) { + 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(); + const visible = modes.length > 1; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -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..82e8b93d376bb --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.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 * 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 { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider, 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, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @IChatSessionsService chatSessionsService: IChatSessionsService, + ) { + super(); + + this._register(autorun(reader => { + const session = sessionsManagementService.activeSession.read(reader); + const providerSession = session ? sessionsProvidersService.getProvider(session.providerId)?.getSession(session.sessionId) : undefined; + if (providerSession instanceof RemoteNewSession) { + this._setSession(providerSession); + } + })); + + // 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 || !this._slotElement) { + 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)); + + const visible = this._models.length > 0; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.ts new file mode 100644 index 0000000000000..f4d9b48a366df --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/branchPicker.test.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 assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvider } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionsProvidersService } from '../../../sessions/browser/sessionsProvidersService.js'; +import { COPILOT_PROVIDER_ID, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; +import { BranchPicker } from '../../browser/branchPicker.js'; +import { IsolationMode } from '../../browser/isolationPicker.js'; + +function createActiveSession(providerId: string, sessionId: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionId}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId, + resource: URI.parse(`test:///session/${sessionId}`), + providerId, + sessionType: 'copilot-cli', + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(0), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +class TestCopilotSession extends mock() { + override readonly loading = observableValue('loading', false); + override readonly branches = observableValue('branches', ['main', 'feature/test']); + override readonly branch = observableValue('branch', 'main'); + override readonly isolationMode = observableValue('isolationMode', 'worktree'); + + override setBranch(branch: string | undefined): void { + this.branch.set(branch, undefined); + } +} + +class TestCopilotProvider extends mock() { + constructor(private readonly sessionId: string, private readonly session: ICopilotChatSession) { + super(); + } + + override readonly id = COPILOT_PROVIDER_ID; + override readonly label = 'Copilot'; + override readonly icon = Codicon.copilot; + override readonly sessionTypes = []; + override readonly browseActions = []; + override readonly onDidChangeSessions = Event.None; + override readonly capabilities = { multipleChatsPerSession: false }; + + getSession(sessionId: string): ICopilotChatSession | undefined { + return sessionId === this.sessionId ? this.session : undefined; + } +} + +class TestSessionsProvidersService extends mock() { + constructor(private readonly provider: TestCopilotProvider) { + super(); + } + + override readonly onDidChangeProviders = Event.None; + override readonly onDidChangeSessions = Event.None; + override readonly onDidReplaceSession = Event.None; + + override getProviders(): ISessionsProvider[] { + return [this.provider]; + } + + override getProvider(providerId: string): T | undefined { + return providerId === this.provider.id ? this.provider as unknown as T : undefined; + } +} + +suite('BranchPicker', () => { + + const disposables = new DisposableStore(); + let activeSession: ReturnType>; + let providerSession: TestCopilotSession; + let showCalls: number; + let instantiationService: TestInstantiationService; + + setup(() => { + const sessionId = `${COPILOT_PROVIDER_ID}:session`; + showCalls = 0; + activeSession = observableValue('activeSession', createActiveSession(COPILOT_PROVIDER_ID, sessionId)); + providerSession = new TestCopilotSession(); + + const provider = new TestCopilotProvider(sessionId, providerSession); + const sessionsProvidersService = new TestSessionsProvidersService(provider); + + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: () => { showCalls++; }, + }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + }); + instantiationService.stub(ISessionsProvidersService, sessionsProvidersService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disables the picker instead of hiding it in folder mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), true); + assert.strictEqual(trigger.getAttribute('aria-hidden'), 'false'); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'true'); + assert.strictEqual(trigger.tabIndex, -1); + + picker.showPicker(); + assert.strictEqual(showCalls, 0); + }); + + test('re-enables the picker when switching back to worktree mode', () => { + providerSession.isolationMode.set('workspace', undefined); + + const picker = disposables.add(instantiationService.createInstance(BranchPicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + const trigger = container.querySelector('a.action-label'); + assert.ok(slot); + assert.ok(trigger); + + providerSession.isolationMode.set('worktree', undefined); + + assert.strictEqual(slot.style.display, ''); + assert.strictEqual(slot.classList.contains('disabled'), false); + assert.strictEqual(trigger.getAttribute('aria-disabled'), 'false'); + assert.strictEqual(trigger.tabIndex, 0); + + picker.showPicker(); + assert.strictEqual(showCalls, 1); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts new file mode 100644 index 0000000000000..7bc75aa03a3a9 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -0,0 +1,842 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatService, ChatSendResult, IChatSendRequestData } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatSessionStatus, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; +import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; +import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; +import { ISessionChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; +import { ISessionWorkspace } from '../../../sessions/common/sessionData.js'; +import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; + +// ---- Helpers ---------------------------------------------------------------- + +function createMockAgentSession(resource: URI, opts?: { + providerType?: string; + title?: string; + archived?: boolean; + read?: boolean; +}): IAgentSession { + const providerType = opts?.providerType ?? AgentSessionProviders.Background; + let archived = opts?.archived ?? false; + let read = opts?.read ?? true; + return new class extends mock() { + override readonly resource = resource; + override readonly providerType = providerType; + override readonly providerLabel = 'Copilot'; + override readonly label = opts?.title ?? 'Test Session'; + override readonly status = ChatSessionStatus.Completed; + override readonly icon = Codicon.copilot; + override readonly timing = { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined }; + override readonly metadata = { repositoryPath: '/test/repo' }; + override isArchived(): boolean { return archived; } + override setArchived(value: boolean): void { archived = value; } + override isPinned(): boolean { return false; } + override setPinned(): void { } + override isRead(): boolean { return read; } + override isMarkedUnread(): boolean { return false; } + override setRead(value: boolean): void { read = value; } + }(); +} + +// ---- Mock Agent Sessions Service -------------------------------------------- + +class MockAgentSessionsModel { + private readonly _sessions: IAgentSession[] = []; + private readonly _onDidChangeSessions = new Emitter(); + readonly onDidChangeSessions = this._onDidChangeSessions.event; + readonly onWillResolve = Event.None; + readonly onDidResolve = Event.None; + readonly onDidChangeSessionArchivedState = Event.None; + readonly resolved = true; + + get sessions(): IAgentSession[] { return [...this._sessions]; } + + getSession(resource: URI): IAgentSession | undefined { + return this._sessions.find(s => s.resource.toString() === resource.toString()); + } + + addSession(session: IAgentSession): void { + this._sessions.push(session); + this._onDidChangeSessions.fire(); + } + + removeSession(resource: URI): void { + const idx = this._sessions.findIndex(s => s.resource.toString() === resource.toString()); + if (idx !== -1) { + this._sessions.splice(idx, 1); + this._onDidChangeSessions.fire(); + } + } + + async resolve(): Promise { } + + dispose(): void { + this._onDidChangeSessions.dispose(); + } +} + +// ---- Provider factory ------------------------------------------------------- + +function createProvider( + disposables: DisposableStore, + model: MockAgentSessionsModel, + opts?: { multiChatEnabled?: boolean }, +): CopilotChatSessionsProvider { + const instantiationService = disposables.add(new TestInstantiationService()); + + const configService = new TestConfigurationService(); + configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? false); + + instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); + instantiationService.stub(IFileDialogService, {}); + instantiationService.stub(ICommandService, { + executeCommand: async (_id: string, ...args: any[]) => { + // Simulate 'github.copilot.cli.sessions.delete' removing the session + const opts = args[0]; + if (opts?.resource) { + model.removeSession(opts.resource); + } + return undefined; + }, + }); + instantiationService.stub(IAgentSessionsService, { + model: model as unknown as IAgentSessionsModel, + onDidChangeSessionArchivedState: Event.None, + getSession: (resource: URI) => model.getSession(resource), + }); + instantiationService.stub(IChatSessionsService, { + getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), + getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), + onDidCommitSession: Event.None, + updateSessionOptions: () => true, + setSessionOption: () => true, + getSessionOption: () => undefined, + onDidChangeOptionGroups: Event.None, + }); + instantiationService.stub(IChatService, { + acquireOrLoadSession: async () => undefined, + sendRequest: async (): Promise => ({ kind: 'sent' as const, data: {} as IChatSendRequestData }), + removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); }, + setChatSessionTitle: () => { }, + }); + instantiationService.stub(IChatWidgetService, { + openSession: async () => undefined, + lastFocusedWidget: undefined, + onDidChangeFocusedSession: Event.None, + }); + instantiationService.stub(ILanguageModelsService, { + lookupLanguageModel: () => undefined, + }); + instantiationService.stub(ILanguageModelToolsService, { + toToolReferences: () => [], + }); + // Stub IInstantiationService so provider can use createInstance for CopilotCLISession + instantiationService.stub(IInstantiationService, instantiationService); + + const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); + return provider; +} + +// ---- Provider factory for send/cancel tests --------------------------------- + +/** + * Creates a provider suitable for testing sendChat flows. Stubs all services + * needed by CopilotCLISession and _sendFirstChat, including IGitService and a + * non-null IChatWidget mock. + * + * The caller can pass a custom `sendRequest` implementation to control the + * lifecycle of the in-flight request. + */ +function createProviderForSendTests( + disposables: DisposableStore, + model: MockAgentSessionsModel, + sendRequest: () => Promise, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }> }, +): CopilotChatSessionsProvider { + const instantiationService = disposables.add(new TestInstantiationService()); + + const configService = new TestConfigurationService(); + configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', false); + + instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); + instantiationService.stub(IFileDialogService, {}); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined }); + instantiationService.stub(IAgentSessionsService, { + model: model as unknown as IAgentSessionsModel, + onDidChangeSessionArchivedState: Event.None, + getSession: (resource: URI) => model.getSession(resource), + }); + instantiationService.stub(IChatSessionsService, { + getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), + getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), + onDidCommitSession: opts?.onDidCommitSession ?? Event.None, + updateSessionOptions: () => true, + setSessionOption: () => true, + getSessionOption: () => undefined, + onDidChangeOptionGroups: Event.None, + }); + instantiationService.stub(IChatService, { + acquireOrLoadSession: async () => undefined, + sendRequest: sendRequest, + removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); }, + setChatSessionTitle: () => { }, + }); + instantiationService.stub(IChatWidgetService, { + openSession: async () => new class extends mock() { + override input = new class extends mock() { + override setPermissionLevel = () => { }; + }(); + }(), + lastFocusedWidget: undefined, + onDidChangeFocusedSession: Event.None, + }); + instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined }); + instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] }); + instantiationService.stub(IGitService, { openRepository: async () => undefined }); + instantiationService.stub(IInstantiationService, instantiationService); + + return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); +} + + + +suite('CopilotChatSessionsProvider', () => { + const disposables = new DisposableStore(); + let model: MockAgentSessionsModel; + + setup(() => { + model = new MockAgentSessionsModel(); + disposables.add(toDisposable(() => model.dispose())); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Provider identity ------- + + test('has correct id and label', () => { + const provider = createProvider(disposables, model); + assert.strictEqual(provider.id, COPILOT_PROVIDER_ID); + assert.strictEqual(provider.sessionTypes.length, 2); + }); + + // ---- Capabilities ------- + + test('capabilities.multipleChatsPerSession is false by default', () => { + const provider = createProvider(disposables, model); + assert.strictEqual(provider.capabilities.multipleChatsPerSession, false); + }); + + test('capabilities.multipleChatsPerSession is true when setting is enabled', () => { + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + assert.strictEqual(provider.capabilities.multipleChatsPerSession, true); + }); + + // ---- Session listing ------- + + test('getSessions returns empty array initially', () => { + const provider = createProvider(disposables, model); + assert.strictEqual(provider.getSessions().length, 0); + }); + + test('getSessions returns adapted sessions from agent model', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 2); + }); + + test('getSessions ignores non-Background/Cloud sessions', () => { + const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' }); + const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' }); + model.addSession(createMockAgentSession(bgResource)); + model.addSession(createMockAgentSession(localResource, { providerType: AgentSessionProviders.Local })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + }); + + test('onDidChangeSessions fires when agent model changes', () => { + const provider = createProvider(disposables, model); + provider.getSessions(); // Initialize cache + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/new-session' }); + model.addSession(createMockAgentSession(resource, { title: 'New Session' })); + + assert.ok(changes.length > 0); + assert.strictEqual(changes[0].added.length, 1); + }); + + // ---- Session creation ------- + // Note: createNewSession tests are limited because CopilotCLISession + // requires IGitService and creates disposables that are hard to clean + // up in isolation. Full integration tests should cover session creation. + + test('createNewSession throws when workspace has no repository', () => { + const provider = createProvider(disposables, model); + const workspace: ISessionWorkspace = { + label: 'empty', + icon: Codicon.folder, + repositories: [], + requiresWorkspaceTrust: true, + }; + + assert.throws(() => provider.createNewSession(workspace), /Workspace has no repository URI/); + }); + + // ---- Session actions ------- + + test('archiveSession sets archived state', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const agentSession = createMockAgentSession(resource); + model.addSession(agentSession); + + const provider = createProvider(disposables, model); + provider.getSessions(); // Initialize cache + + const session = provider.getSessions()[0]; + provider.archiveSession(session.sessionId); + + assert.strictEqual(agentSession.isArchived(), true); + }); + + test('unarchiveSession clears archived state', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const agentSession = createMockAgentSession(resource, { archived: true }); + model.addSession(agentSession); + + const provider = createProvider(disposables, model); + provider.getSessions(); + + const session = provider.getSessions()[0]; + provider.unarchiveSession(session.sessionId); + + assert.strictEqual(agentSession.isArchived(), false); + }); + + test('setRead marks session as read', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const agentSession = createMockAgentSession(resource, { read: false }); + model.addSession(agentSession); + + const provider = createProvider(disposables, model); + provider.getSessions(); + + const session = provider.getSessions()[0]; + provider.setRead(session.sessionId, true); + + assert.strictEqual(agentSession.isRead(), true); + }); + + // ---- Single-chat mode (multi-chat disabled) ------- + + test('single-chat mode: each session has exactly one chat', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: false }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].chats.get().length, 1); + assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); + }); + + test('single-chat mode: sendAndCreateChat throws for unknown session', async () => { + const provider = createProvider(disposables, model, { multiChatEnabled: false }); + await assert.rejects( + () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + /not found or not a new session/, + ); + }); + + // ---- Multi-chat mode ------- + + suite('multi-chat (setting enabled)', () => { + + test('getSessions groups chats by session group', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + // Without explicit grouping, each chat is its own session + assert.strictEqual(sessions.length, 2); + }); + + test('session title comes from primary (first) chat', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { title: 'Primary Title' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions[0].title.get(), 'Primary Title'); + }); + + test('session has mainChat set to the first chat', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + assert.ok(sessions[0].mainChat); + assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString()); + }); + + test('sendAndCreateChat throws for unknown session when no untitled session exists', async () => { + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + await assert.rejects( + () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + /not found/, + ); + }); + + test('deleteSession removes session from model and list', async () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + + await provider.deleteSession(sessions[0].sessionId); + + const remainingSessions = provider.getSessions(); + assert.strictEqual(remainingSessions.length, 1); + assert.strictEqual(remainingSessions[0].title.get(), 'Session 2'); + }); + + test('deleteChat with single chat delegates to deleteSession', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + const session = sessions[0]; + + await provider.deleteChat(session.sessionId, resource); + + // Model should no longer have the session + assert.strictEqual(model.sessions.length, 0); + }); + + test('deleteChat throws when multi-chat is disabled', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: false }); + const sessions = provider.getSessions(); + const session = sessions[0]; + + await assert.rejects( + () => provider.deleteChat(session.sessionId, resource), + /not supported when multi-chat is disabled/, + ); + }); + + test('session group cache is invalidated on session removal', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + + // Initialize sessions + let sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + + // Remove one from the model + model.removeSession(resource1); + + // Re-fetch + sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].title.get(), 'Session 2'); + }); + + test('resolveWorkspace creates proper workspace structure', () => { + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const uri = URI.file('/test/project'); + + const workspace = provider.resolveWorkspace(uri); + + assert.strictEqual(workspace.label, 'project'); + assert.strictEqual(workspace.repositories.length, 1); + assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString()); + assert.strictEqual(workspace.requiresWorkspaceTrust, true); + }); + + test('chats observable updates when group model changes', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + + // Both are separate sessions initially + const session1 = sessions[0]; + assert.strictEqual(session1.chats.get().length, 1); + }); + + test('session status aggregates across chats', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + // With a single chat, session status should match the chat status + assert.ok(sessions[0].status.get() !== undefined); + }); + + test('session isRead aggregates across all chats', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { read: true })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions[0].isRead.get(), true); + }); + + test('session isRead is false when any chat is unread', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { read: false })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions[0].isRead.get(), false); + }); + + test('removing a chat from a group fires changed (not removed) with correct sessionId', async () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + + // Manually group both chats under the first session + const chat2Id = sessions[1].sessionId; + // Access the group model indirectly by deleting the second session's group + // and re-adding its chat to the first group via deleteChat flow + // Instead, simulate by removing the second chat from the model + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + model.removeSession(resource2); + + // The removed chat was standalone, so it should fire a removed event + assert.ok(changes.length > 0); + const lastChange = changes[changes.length - 1]; + assert.strictEqual(lastChange.removed.length, 1); + assert.strictEqual(lastChange.removed[0].sessionId, chat2Id); + }); + + test('getSessions does not create duplicate groups on repeated calls', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + model.addSession(createMockAgentSession(resource)); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + + // Call getSessions multiple times + const sessions1 = provider.getSessions(); + const sessions2 = provider.getSessions(); + + assert.strictEqual(sessions1.length, 1); + assert.strictEqual(sessions2.length, 1); + // Should return the same cached session object + assert.strictEqual(sessions1[0], sessions2[0]); + }); + + test('changed events are not duplicated when multiple chats update', () => { + const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); + const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' }); + model.addSession(createMockAgentSession(resource1, { title: 'Session 1' })); + model.addSession(createMockAgentSession(resource2, { title: 'Session 2' })); + + const provider = createProvider(disposables, model, { multiChatEnabled: true }); + provider.getSessions(); // Initialize + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + // Trigger a refresh that updates both sessions + model.addSession(createMockAgentSession( + URI.from({ scheme: AgentSessionProviders.Background, path: '/session-3' }), + { title: 'Session 3' } + )); + + // Each event should not have duplicates in the changed array + for (const change of changes) { + const changedIds = change.changed.map(s => s.sessionId); + const uniqueIds = new Set(changedIds); + assert.strictEqual(changedIds.length, uniqueIds.size, 'Changed events should not have duplicates'); + } + }); + }); + + // ---- Browse actions ------- + + test('has folder and repo browse actions', () => { + const provider = createProvider(disposables, model); + assert.strictEqual(provider.browseActions.length, 2); + assert.strictEqual(provider.browseActions[0].providerId, COPILOT_PROVIDER_ID); + assert.strictEqual(provider.browseActions[1].providerId, COPILOT_PROVIDER_ID); + }); + + // ---- Uncommitted temp session cleanup ------------------------------------ + + suite('uncommitted temp session cleanup', () => { + const workspace: ISessionWorkspace = { + label: 'repo', + icon: Codicon.folder, + repositories: [{ + uri: URI.file('/test/repo'), + workingDirectory: undefined, + detail: undefined, + baseBranchName: undefined, + baseBranchProtected: undefined, + }], + requiresWorkspaceTrust: false, + }; + + /** + * Returns a provider wired up so that sendRequest keeps the request + * in-flight indefinitely. Also returns helpers to resolve the request + * as a cancellation (so the provider cleans up promptly in tests). + */ + function makeInFlightProvider(): { + provider: CopilotChatSessionsProvider; + cancelRequest: () => void; + } { + let resolveComplete!: () => void; + let resolveCreated!: (r: IChatResponseModel) => void; + const responseCompletePromise = new Promise(r => { resolveComplete = r; }); + const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + + const provider = createProviderForSendTests(disposables, model, async () => ({ + kind: 'sent' as const, + data: { + responseCompletePromise, + responseCreatedPromise, + agent: new class extends mock() { }(), + } as IChatSendRequestData, + })); + + return { + provider, + cancelRequest: () => { + resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); + resolveComplete(); + }, + }; + } + + /** Wait for the provider to fire an "added" session change event. */ + function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise { + return new Promise(resolve => { + const d = provider.onDidChangeSessions(e => { + if (e.added.length > 0) { + d.dispose(); + resolve(); + } + }); + }); + } + + test('deleteSession removes a temp session that is awaiting commit', async () => { + const { provider, cancelRequest } = makeInFlightProvider(); + + const newSession = provider.createNewSession(workspace); + const sessionId = newSession.sessionId; + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); + + await provider.deleteSession(sessionId); + assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after deleteSession'); + + // Clean up in-flight request so _sendFirstChat resolves quickly + cancelRequest(); + await sendPromise.catch(() => { /* expected to reject */ }); + }); + + test('archiveSession removes a temp session that is awaiting commit', async () => { + const { provider, cancelRequest } = makeInFlightProvider(); + + const newSession = provider.createNewSession(workspace); + const sessionId = newSession.sessionId; + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); + + await provider.archiveSession(sessionId); + assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after archiveSession'); + + cancelRequest(); + await sendPromise.catch(() => { /* expected to reject */ }); + }); + + /** + * Returns a provider where the commit event is controllable. The + * caller can fire the commit event at the right moment to simulate + * the session being committed mid-request, then cancel the request + * afterwards. The session should persist after cancellation. + */ + function makeCommittableProvider(): { + provider: CopilotChatSessionsProvider; + commitSession: (original: URI, committed: URI) => void; + cancelRequest: () => void; + } { + let resolveComplete!: () => void; + let resolveCreated!: (r: IChatResponseModel) => void; + const responseCompletePromise = new Promise(r => { resolveComplete = r; }); + const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + + const commitEmitter = disposables.add(new Emitter<{ original: URI; committed: URI }>()); + + const provider = createProviderForSendTests(disposables, model, async () => ({ + kind: 'sent' as const, + data: { + responseCompletePromise, + responseCreatedPromise, + agent: new class extends mock() { }(), + } as IChatSendRequestData, + }), { onDidCommitSession: commitEmitter.event }); + + return { + provider, + commitSession: (original, committed) => commitEmitter.fire({ original, committed }), + cancelRequest: () => { + resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); + resolveComplete(); + }, + }; + } + + test('stopping a committed session keeps it in the list', async () => { + const { provider, commitSession, cancelRequest } = makeCommittableProvider(); + + const newSession = provider.createNewSession(workspace); + const sessionId = newSession.sessionId; + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); + + // Get the temp session's resource so we can fire the commit event + const tempSession = provider.getSessions()[0]; + const tempResource = tempSession.resource; + + // Simulate commit: the agent created the worktree, so the URI + // swaps from untitled to a real committed resource. + const committedResource = URI.from({ scheme: AgentSessionProviders.Background, path: `/committed-${Date.now()}` }); + const committedAgentSession = createMockAgentSession(committedResource); + model.addSession(committedAgentSession); + commitSession(tempResource, committedResource); + + // _sendFirstChat should complete successfully now + await sendPromise; + + assert.strictEqual(provider.getSessions().length, 1, 'committed session should remain in list'); + + // Now cancel the request — session must stay + cancelRequest(); + + assert.strictEqual(provider.getSessions().length, 1, 'committed session should persist after stopping'); + }); + + test('cancelling the request before commit keeps the session with completed status', async () => { + const { provider, cancelRequest } = makeInFlightProvider(); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + const newSession = provider.createNewSession(workspace); + const sessionId = newSession.sessionId; + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' }); + await added; + + assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight'); + assert.ok(changes.some(e => e.added.some(s => s.sessionId === sessionId)), 'added event should have fired'); + + // Simulate user stopping the request + cancelRequest(); + await sendPromise; + + assert.strictEqual(provider.getSessions().length, 1, 'session should stay in list after cancellation'); + assert.ok( + changes.some(e => e.changed.some(s => s.sessionId === sessionId)), + 'changed event should have fired', + ); + + // Clean up the kept session so it doesn't leak + await provider.deleteSession(sessionId); + }); + }); +}); 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 deleted file mode 100644 index 0ef758704325e..0000000000000 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ /dev/null @@ -1,577 +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/fileTreeView.css'; -import * as dom from '../../../../base/browser/dom.js'; -import { IAsyncDataSource, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; -import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; -import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; -import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent } 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 { FileKind, IFileService, IFileStat } from '../../../../platform/files/common/files.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; -import { WorkbenchCompressibleAsyncDataTree } from '../../../../platform/list/browser/listService.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; -import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; -import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; -import { IViewDescriptorService } from '../../../../workbench/common/views.js'; -import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.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 { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -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 { basename } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; - -const $ = dom.$; - -// --- Constants - -export const FILE_TREE_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.fileTreeContainer'; -export const FILE_TREE_VIEW_ID = 'workbench.view.agentSessions.fileTree'; - -// --- Tree Item - -interface IFileTreeItem { - readonly uri: URI; - readonly name: string; - readonly isDirectory: boolean; -} - -// --- Data Source - -class FileTreeDataSource implements IAsyncDataSource { - - constructor( - private readonly fileService: IFileService, - private readonly logService: ILogService, - ) { } - - hasChildren(element: URI | IFileTreeItem): boolean { - if (URI.isUri(element)) { - return true; // root - } - return element.isDirectory; - } - - async getChildren(element: URI | IFileTreeItem): Promise { - const uri = URI.isUri(element) ? element : element.uri; - - try { - const stat = await this.fileService.resolve(uri); - if (!stat.children) { - return []; - } - - return stat.children - .map((child: IFileStat): IFileTreeItem => ({ - uri: child.resource, - name: child.name, - isDirectory: child.isDirectory, - })) - .sort((a, b) => { - // Directories first, then alphabetical - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - } catch (e) { - this.logService.warn(`[FileTreeView] Error fetching children for ${uri.toString()}:`, e); - return []; - } - } -} - -// --- Delegate - -class FileTreeDelegate implements IListVirtualDelegate { - getHeight(): number { - return 22; - } - - getTemplateId(): string { - return FileTreeRenderer.TEMPLATE_ID; - } -} - -// --- Renderer - -interface IFileTreeTemplate { - readonly label: IResourceLabel; - readonly templateDisposables: DisposableStore; -} - -class FileTreeRenderer implements ICompressibleTreeRenderer { - static readonly TEMPLATE_ID = 'fileTreeRenderer'; - readonly templateId = FileTreeRenderer.TEMPLATE_ID; - - constructor( - private readonly labels: ResourceLabels, - @ILabelService private readonly labelService: ILabelService, - ) { } - - renderTemplate(container: HTMLElement): IFileTreeTemplate { - const templateDisposables = new DisposableStore(); - const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); - return { label, templateDisposables }; - } - - renderElement(node: ITreeNode, _index: number, templateData: IFileTreeTemplate): void { - const element = node.element; - templateData.label.element.style.display = 'flex'; - templateData.label.setFile(element.uri, { - fileKind: element.isDirectory ? FileKind.FOLDER : FileKind.FILE, - hidePath: true, - }); - } - - renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IFileTreeTemplate): void { - const compressed = node.element; - const lastElement = compressed.elements[compressed.elements.length - 1]; - - templateData.label.element.style.display = 'flex'; - - const label = compressed.elements.map(e => e.name); - templateData.label.setResource({ resource: lastElement.uri, name: label }, { - fileKind: lastElement.isDirectory ? FileKind.FOLDER : FileKind.FILE, - separator: this.labelService.getSeparator(lastElement.uri.scheme), - }); - } - - disposeTemplate(templateData: IFileTreeTemplate): void { - templateData.templateDisposables.dispose(); - } -} - -// --- Compression Delegate - -class FileTreeCompressionDelegate { - isIncompressible(element: IFileTreeItem): boolean { - return !element.isDirectory; - } -} - -// --- View Pane - -export class FileTreeViewPane extends ViewPane { - - private bodyContainer: HTMLElement | undefined; - private welcomeContainer: HTMLElement | undefined; - private treeContainer: HTMLElement | undefined; - - private tree: WorkbenchCompressibleAsyncDataTree | undefined; - - private readonly renderDisposables = this._register(new DisposableStore()); - private readonly treeInputDisposable = this._register(new MutableDisposable()); - - private currentBodyHeight = 0; - private currentBodyWidth = 0; - - /** - * Observable that tracks the root URI for the file tree. - * - For background sessions: the worktree or repository local path - * - For cloud sessions: a github-remote-file:// URI derived from the session's repository metadata - * - For local sessions: the workspace folder - */ - private readonly treeRootUri: IObservable; - - 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, - @IFileService private readonly fileService: IFileService, - @IEditorService private readonly editorService: IEditorService, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ILogService private readonly logService: ILogService, - ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - - // Track active session changes AND session model updates (metadata/changes can arrive later) - const sessionsChangedSignal = observableFromEvent( - this, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - this.treeRootUri = derived(reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - sessionsChangedSignal.read(reader); // re-evaluate when sessions data updates - return this.resolveTreeRoot(activeSession); - }); - } - - /** - * Determines the root URI for the file tree based on the active session type. - * Tries multiple data sources: IActiveSessionItem fields, agent session model metadata, - * and file change URIs as a last resort. - */ - private resolveTreeRoot(activeSession: IActiveSessionItem | 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; - } - if (activeSession.repository && activeSession.repository.scheme === 'file') { - this.logService.info(`[FileTreeView] Using repository: ${activeSession.repository.toString()}`); - return activeSession.repository; - } - - // 2. Query the agent session model directly for metadata - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - if (agentSession?.metadata) { - const metadata = agentSession.metadata; - - // Background sessions: local paths (try multiple known metadata keys) - const workingDir = metadata.workingDirectoryPath as string | undefined; - if (workingDir) { - this.logService.info(`[FileTreeView] Using metadata.workingDirectoryPath: ${workingDir}`); - return URI.file(workingDir); - } - const worktreePath = metadata.worktreePath as string | undefined; - if (worktreePath) { - this.logService.info(`[FileTreeView] Using metadata.worktreePath: ${worktreePath}`); - return URI.file(worktreePath); - } - const repositoryPath = metadata.repositoryPath as string | undefined; - if (repositoryPath) { - this.logService.info(`[FileTreeView] Using metadata.repositoryPath: ${repositoryPath}`); - return URI.file(repositoryPath); - } - - // Cloud sessions: GitHub repo info in metadata - const repoUri = this.extractRepoUriFromMetadata(metadata); - if (repoUri) { - return repoUri; - } - } - - // 3. For cloud/remote sessions: try to infer repo from file change URIs - if (sessionType === AgentSessionProviders.Cloud || sessionType === AgentSessionProviders.Codex) { - const repoUri = this.inferRepoFromChanges(activeSession.resource); - if (repoUri) { - this.logService.info(`[FileTreeView] Inferred repo from changes: ${repoUri.toString()}`); - return repoUri; - } - } - - // 4. Try to parse the repository URI as a GitHub URL - if (activeSession.repository) { - const repoStr = activeSession.repository.toString(); - const parsed = this.parseGitHubUrl(repoStr); - if (parsed) { - this.logService.info(`[FileTreeView] Parsed repository URI as GitHub: ${parsed.owner}/${parsed.repo}`); - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/HEAD`, - }); - } - } - - this.logService.trace(`[FileTreeView] No tree root resolved for session ${activeSession.resource.toString()} (type: ${sessionType})`); - return undefined; - } - - /** - * Extracts a github-remote-file:// URI from session metadata, trying various known fields. - */ - private extractRepoUriFromMetadata(metadata: { readonly [key: string]: unknown }): URI | undefined { - // repositoryNwo: "owner/repo" - const repositoryNwo = metadata.repositoryNwo as string | undefined; - if (repositoryNwo && repositoryNwo.includes('/')) { - this.logService.info(`[FileTreeView] Using metadata.repositoryNwo: ${repositoryNwo}`); - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${repositoryNwo}/HEAD`, - }); - } - - // repositoryUrl: "https://github.com/owner/repo" - const repositoryUrl = metadata.repositoryUrl as string | undefined; - if (repositoryUrl) { - const parsed = this.parseGitHubUrl(repositoryUrl); - if (parsed) { - this.logService.info(`[FileTreeView] Using metadata.repositoryUrl: ${repositoryUrl}`); - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/HEAD`, - }); - } - } - - // repository: could be "owner/repo" or a URL - const repository = metadata.repository as string | undefined; - if (repository) { - if (repository.includes('/') && !repository.includes(':')) { - // Looks like "owner/repo" - this.logService.info(`[FileTreeView] Using metadata.repository as nwo: ${repository}`); - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${repository}/HEAD`, - }); - } - const parsed = this.parseGitHubUrl(repository); - if (parsed) { - this.logService.info(`[FileTreeView] Using metadata.repository as URL: ${repository}`); - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/HEAD`, - }); - } - } - - return undefined; - } - - /** - * Attempts to infer the repository from the session's file change URIs. - * Cloud sessions have changes with URIs that reveal the repository. - */ - private inferRepoFromChanges(sessionResource: URI): URI | undefined { - const agentSession = this.agentSessionsService.getSession(sessionResource); - if (!agentSession?.changes || !(agentSession.changes instanceof Array)) { - return undefined; - } - - for (const change of agentSession.changes) { - const fileUri = isIChatSessionFileChange2(change) - ? (change.modifiedUri ?? change.uri) - : change.modifiedUri; - - if (!fileUri) { - continue; - } - - const parsed = this.parseRepoFromFileUri(fileUri); - if (parsed) { - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${parsed.owner}/${parsed.repo}/${parsed.ref}`, - }); - } - } - - return undefined; - } - - /** - * Tries to extract GitHub owner/repo from a file change URI. - * Handles various URI formats used by cloud sessions. - */ - private parseRepoFromFileUri(uri: URI): { owner: string; repo: string; ref: string } | undefined { - // Pattern: vscode-vfs://github/{owner}/{repo}/... - if (uri.authority === 'github' || uri.authority?.startsWith('github')) { - const parts = uri.path.split('/').filter(Boolean); - if (parts.length >= 2) { - return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; - } - } - - // Pattern: github://{owner}/{repo}/... or github1s://{owner}/{repo}/... - if (uri.scheme === 'github' || uri.scheme === 'github1s') { - const parts = uri.authority ? uri.authority.split('/') : uri.path.split('/').filter(Boolean); - if (parts.length >= 2) { - return { owner: parts[0], repo: parts[1], ref: 'HEAD' }; - } - } - - // Pattern: https://github.com/{owner}/{repo}/... - return this.parseGitHubUrl(uri.toString()); - } - - private parseGitHubUrl(url: string): { owner: string; repo: string; ref: string } | undefined { - const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url) - || /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(url); - return match ? { owner: match[1], repo: match[2], ref: 'HEAD' } : undefined; - } - - protected override renderBody(container: HTMLElement): void { - super.renderBody(container); - - this.bodyContainer = dom.append(container, $('.file-tree-view-body')); - - // Welcome message for empty state - this.welcomeContainer = dom.append(this.bodyContainer, $('.file-tree-welcome')); - const welcomeIcon = dom.append(this.welcomeContainer, $('.file-tree-welcome-icon')); - welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.repoClone)); - const welcomeMessage = dom.append(this.welcomeContainer, $('.file-tree-welcome-message')); - welcomeMessage.textContent = localize('fileTreeView.noRepository', "No repository available for this session."); - - // Tree container - this.treeContainer = dom.append(this.bodyContainer, $('.file-tree-container.show-file-icons')); - this._register(createFileIconThemableTreeContainerScope(this.treeContainer, this.themeService)); - - this._register(this.onDidChangeBodyVisibility(visible => { - if (visible) { - this.onVisible(); - } else { - this.renderDisposables.clear(); - } - })); - - if (this.isBodyVisible()) { - this.onVisible(); - } - } - - private onVisible(): void { - this.renderDisposables.clear(); - this.logService.info('[FileTreeView] onVisible called'); - - // Create tree if needed - if (!this.tree && this.treeContainer) { - const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); - const dataSource = new FileTreeDataSource(this.fileService, this.logService); - - this.tree = this.instantiationService.createInstance( - WorkbenchCompressibleAsyncDataTree, - 'FileTreeView', - this.treeContainer, - new FileTreeDelegate(), - new FileTreeCompressionDelegate(), - [this.instantiationService.createInstance(FileTreeRenderer, resourceLabels)], - dataSource, - { - accessibilityProvider: { - getAriaLabel: (element: IFileTreeItem) => element.name, - getWidgetAriaLabel: () => localize('fileTreeView', "File Tree") - }, - identityProvider: { - getId: (element: IFileTreeItem) => element.uri.toString() - }, - compressionEnabled: true, - collapseByDefault: (_e: IFileTreeItem) => true, - } - ); - } - - // Handle tree open events (open files in editor) - if (this.tree) { - this.renderDisposables.add(this.tree.onDidOpen(async (e) => { - if (!e.element || e.element.isDirectory) { - return; - } - - await this.editorService.openEditor({ - resource: e.element.uri, - options: e.editorOptions, - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - })); - } - - // React to active session changes - let lastRootUri: URI | undefined; - this.renderDisposables.add(autorun(reader => { - const rootUri = this.treeRootUri.read(reader); - const hasRoot = !!rootUri; - - dom.setVisibility(hasRoot, this.treeContainer!); - dom.setVisibility(!hasRoot, this.welcomeContainer!); - - if (this.tree && rootUri && !isEqual(rootUri, lastRootUri)) { - lastRootUri = rootUri; - this.updateTitle(basename(rootUri.path) || rootUri.toString()); - this.treeInputDisposable.clear(); - this.tree.setInput(rootUri).then(() => { - this.layoutTree(); - }); - } else if (!rootUri && lastRootUri) { - lastRootUri = undefined; - } - })); - } - - private layoutTree(): void { - if (!this.tree) { - return; - } - this.tree.layout(this.currentBodyHeight, this.currentBodyWidth); - } - - protected override layoutBody(height: number, width: number): void { - super.layoutBody(height, width); - this.currentBodyHeight = height; - this.currentBodyWidth = width; - this.layoutTree(); - } - - override focus(): void { - super.focus(); - this.tree?.domFocus(); - } - - override dispose(): void { - this.tree?.dispose(); - this.tree = undefined; - super.dispose(); - } -} - -// --- View Pane Container - -export class FileTreeViewPaneContainer extends ViewPaneContainer { - constructor( - @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, - @ITelemetryService telemetryService: ITelemetryService, - @IInstantiationService instantiationService: IInstantiationService, - @IContextMenuService contextMenuService: IContextMenuService, - @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService, - @IConfigurationService configurationService: IConfigurationService, - @IExtensionService extensionService: IExtensionService, - @IWorkspaceContextService contextService: IWorkspaceContextService, - @IViewDescriptorService viewDescriptorService: IViewDescriptorService, - @ILogService logService: ILogService, - ) { - super(FILE_TREE_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); - } - - override create(parent: HTMLElement): void { - super.create(parent); - parent.classList.add('file-tree-viewlet'); - } -} 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/fileTreeView/browser/media/fileTreeView.css b/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css deleted file mode 100644 index 3affb3068f9ff..0000000000000 --- a/src/vs/sessions/contrib/fileTreeView/browser/media/fileTreeView.css +++ /dev/null @@ -1,36 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.file-tree-view-body { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} - -.file-tree-view-body .file-tree-welcome { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 16px; - gap: 8px; - color: var(--vscode-descriptionForeground); -} - -.file-tree-view-body .file-tree-welcome-icon { - font-size: 24px; - opacity: 0.6; -} - -.file-tree-view-body .file-tree-welcome-message { - font-size: 12px; - text-align: center; -} - -.file-tree-view-body .file-tree-container { - flex: 1; - overflow: hidden; -} 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..3b46c17564ae0 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * 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); + } + + /** + * Rerun failed jobs in a GitHub Actions workflow run. + */ + async rerunFailedJobs(owner: string, repo: string, runId: number): Promise { + await this._apiClient.request( + 'POST', + `/repos/${e(owner)}/${e(repo)}/actions/runs/${runId}/rerun-failed-jobs`, + 'githubApi.rerunFailedJobs' + ); + } + + /** + * 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..c126b607d67e6 --- /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 gitHubInfo = session.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return; + } + const prModel = this._gitHubService.getPullRequest(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number); + 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..3f0cec668525e --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.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 { 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); + } + + /** + * Rerun a failed check by extracting the workflow run ID from its details URL + * and calling the GitHub Actions rerun-failed-jobs API, then refresh status. + */ + async rerunFailedCheck(check: IGitHubCICheck): Promise { + const runId = parseWorkflowRunId(check.detailsUrl); + if (!runId) { + this._logService.warn(`${LOG_PREFIX} Cannot rerun check "${check.name}": no workflow run ID found in detailsUrl`); + return; + } + await this._fetcher.rerunFailedJobs(this.owner, this.repo, runId); + await this.refresh(); + } + + /** + * 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(); + } +} + +/** + * Extract the GitHub Actions workflow run ID from a check run's details URL. + * URLs follow the pattern: `https://github.com/{owner}/{repo}/actions/runs/{run_id}/job/{job_id}` + */ +export function parseWorkflowRunId(detailsUrl: string | undefined): number | undefined { + if (!detailsUrl) { + return undefined; + } + const match = /\/actions\/runs\/(?\d+)/.exec(detailsUrl); + const runId = match?.groups?.runId; + return runId ? parseInt(runId, 10) : undefined; +} 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..8bcfbc0fbe9a2 --- /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 = 60_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..0c338ceb251d4 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -0,0 +1,459 @@ +/*--------------------------------------------------------------------------------------------- + * 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')); + }); + + test('rerunFailedJobs sends POST to correct endpoint', async () => { + mockApi.setNextResponse(undefined); + + await fetcher.rerunFailedJobs('myOwner', 'myRepo', 12345); + + assert.strictEqual(mockApi.requestCalls.length, 1); + assert.deepStrictEqual(mockApi.requestCalls[0], { + method: 'POST', + path: '/repos/myOwner/myRepo/actions/runs/12345/rerun-failed-jobs', + body: undefined, + }); + }); +}); + +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..e811b76c3cbc1 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -0,0 +1,307 @@ +/*--------------------------------------------------------------------------------------------- + * 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, parseWorkflowRunId } 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'); + }); +}); + +suite('parseWorkflowRunId', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('extracts run ID from GitHub Actions URL', () => { + assert.strictEqual( + parseWorkflowRunId('https://github.com/microsoft/vscode/actions/runs/12345/job/67890'), + 12345, + ); + }); + + test('extracts run ID from URL without job segment', () => { + assert.strictEqual( + parseWorkflowRunId('https://github.com/owner/repo/actions/runs/99999'), + 99999, + ); + }); + + test('returns undefined for non-Actions URL', () => { + assert.strictEqual(parseWorkflowRunId('https://example.com/check/1'), undefined); + }); + + test('returns undefined for undefined input', () => { + assert.strictEqual(parseWorkflowRunId(undefined), undefined); + }); +}); + + +//#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/layout/browser/layout.contribution.ts b/src/vs/sessions/contrib/layout/browser/layout.contribution.ts new file mode 100644 index 0000000000000..1ecb970094632 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/layout.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 { LayoutController } from './layoutController.js'; + +registerWorkbenchContribution2(LayoutController.ID, LayoutController, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/layout/browser/layoutController.ts b/src/vs/sessions/contrib/layout/browser/layoutController.ts new file mode 100644 index 0000000000000..3eabae3be2ddd --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/layoutController.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../base/common/uri.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 '../../changes/common/changes.js'; + +interface IPendingTurnState { + readonly hadChangesBeforeSend: boolean; + readonly submittedAt: number; +} + +export class LayoutController extends Disposable { + + static readonly ID = 'workbench.contrib.sessionsLayoutController'; + + private readonly _pendingTurnStateByResource = new ResourceMap(); + private readonly _panelVisibilityBySession = 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 — sync auxiliary bar and panel visibility + this._register(autorun(reader => { + const activeSession = this._sessionManagementService.activeSession.read(reader); + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + this._syncAuxiliaryBarVisibility(activeSessionHasChanges); + this._syncPanelVisibility(activeSession?.resource); + })); + + // 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(), + }); + })); + + // Track panel visibility changes by the user + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId !== Parts.PANEL_PART) { + return; + } + const activeSession = this._sessionManagementService.activeSession.get(); + if (activeSession) { + this._panelVisibilityBySession.set(activeSession.resource, e.visible); + } + })); + } + + private _syncAuxiliaryBarVisibility(hasChanges: boolean): void { + if (hasChanges) { + this._viewsService.openView(CHANGES_VIEW_ID, false); + } else { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + } + + private _syncPanelVisibility(sessionResource: URI | undefined): void { + if (!sessionResource) { + this._layoutService.setPartHidden(true, Parts.PANEL_PART); + return; + } + + const wasVisible = this._panelVisibilityBySession.get(sessionResource); + // Default to hidden if we have no record for this session + this._layoutService.setPartHidden(wasVisible !== true, Parts.PANEL_PART); + } +} diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHost.contribution.ts new file mode 100644 index 0000000000000..1777a5630b9df --- /dev/null +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHost.contribution.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; +import { LocalAgentHostSessionsProvider } from './localAgentHostSessionsProvider.js'; + +/** + * Registers the {@link LocalAgentHostSessionsProvider} as a sessions provider + * when `chat.agentHost.enabled` is true. + * + * The existing {@link AgentHostContribution} (from `chat/electron-browser/chat.contribution.js`) + * handles all the heavy lifting — agent discovery, session handler registration, + * language model providers, customization harness — via {@link IChatSessionsService}. + * This contribution only bridges the session listing and lifecycle to the + * {@link ISessionsProvidersService} layer used by the Sessions app's UI. + */ +class LocalAgentHostContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.localAgentHostContribution'; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + if (!configurationService.getValue(AgentHostEnabledSettingId)) { + return; + } + + const provider = this._register(instantiationService.createInstance(LocalAgentHostSessionsProvider)); + this._register(sessionsProvidersService.registerProvider(provider)); + } +} + +registerWorkbenchContribution2(LocalAgentHostContribution.ID, LocalAgentHostContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts new file mode 100644 index 0000000000000..f21a3e70d38f0 --- /dev/null +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -0,0 +1,601 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.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 { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; +import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ISendRequestOptions, ISessionChangeEvent, ISessionsBrowseAction, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { IChat, ISession, ISessionWorkspace, SessionStatus, type IGitHubInfo } from '../../sessions/common/sessionData.js'; + +const LOCAL_PROVIDER_ID = 'local-agent-host'; + +/** Default provider when session metadata does not carry one. */ +const DEFAULT_AGENT_PROVIDER = 'copilot'; + +/** + * Derives the session type / URI scheme from an agent provider name. + * Must match the type string registered by AgentHostContribution + * (`agent-host-${agent.provider}`). + */ +function sessionTypeForProvider(provider: string): string { + return `agent-host-${provider}`; +} + +/** Session type for the local agent host. ID matches the targetChatSessionType on language models. */ +const LocalAgentHostSessionType: ISessionType = { + id: sessionTypeForProvider(DEFAULT_AGENT_PROVIDER), + label: localize('localAgentHost', "Local Agent Host"), + icon: Codicon.vm, +}; + +/** + * Adapts agent host session metadata into an {@link ISession} for the + * local agent host. Also exposes settable observables so the cache + * layer can push live updates. + */ +class LocalSessionAdapter implements ISession { + + readonly sessionId: string; + readonly resource: URI; + readonly providerId: string; + readonly sessionType: string; + readonly icon = Codicon.vm; + readonly createdAt: Date; + readonly workspace: ISettableObservable; + readonly title: ISettableObservable; + readonly updatedAt: ISettableObservable; + readonly status = observableValue('status', SessionStatus.Completed); + readonly changes = observableValue('changes', []); + readonly modelId = observableValue('modelId', undefined); + readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); + readonly loading = observableValue('loading', false); + readonly isArchived = observableValue('isArchived', false); + readonly isRead = observableValue('isRead', true); + readonly description: ISettableObservable; + readonly lastTurnEnd: ISettableObservable; + readonly gitHubInfo = observableValue('gitHubInfo', undefined); + + readonly mainChat: IChat; + readonly chats: IObservable; + + readonly agentProvider: string; + + constructor( + metadata: IAgentSessionMetadata, + providerId: string, + resourceScheme: string, + logicalSessionType: string, + ) { + const rawId = AgentSession.id(metadata.session); + this.agentProvider = AgentSession.provider(metadata.session) ?? DEFAULT_AGENT_PROVIDER; + this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); + this.sessionId = `${providerId}:${this.resource.toString()}`; + this.providerId = providerId; + this.sessionType = logicalSessionType; + this.createdAt = new Date(metadata.startTime); + this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`); + this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); + this.description = observableValue('description', new MarkdownString().appendText(localize('localAgentHostDescription', "Local"))); + this.workspace = observableValue('workspace', metadata.workingDirectory + ? LocalAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory) + : undefined); + + this.mainChat = { + resource: this.resource, + createdAt: this.createdAt, + title: this.title, + updatedAt: this.updatedAt, + status: this.status, + changes: this.changes, + modelId: this.modelId, + mode: this.mode, + isArchived: this.isArchived, + isRead: this.isRead, + description: this.description, + lastTurnEnd: this.lastTurnEnd, + }; + this.chats = constObservable([this.mainChat]); + } + + update(metadata: IAgentSessionMetadata): boolean { + let didChange = false; + + const summary = metadata.summary; + if (summary !== undefined && summary !== this.title.get()) { + this.title.set(summary, undefined); + didChange = true; + } + + const modifiedTime = metadata.modifiedTime; + if (this.updatedAt.get().getTime() !== modifiedTime) { + this.updatedAt.set(new Date(modifiedTime), undefined); + didChange = true; + } + + const currentLastTurnEndTime = this.lastTurnEnd.get()?.getTime(); + const nextLastTurnEndTime = modifiedTime ? modifiedTime : undefined; + if (currentLastTurnEndTime !== nextLastTurnEndTime) { + this.lastTurnEnd.set(nextLastTurnEndTime !== undefined ? new Date(nextLastTurnEndTime) : undefined, undefined); + didChange = true; + } + + return didChange; + } +} + +/** + * Sessions provider for the local agent host. + * + * Implements {@link ISessionsProvider} to surface local agent host sessions + * in the Sessions app's session list, workspace picker, and session management UI. + * + * The heavy lifting (agent discovery, session handlers, language model providers, + * customization harness) is handled by the existing {@link AgentHostContribution} + * which is already active in the Sessions app. This provider only bridges the + * session listing and lifecycle to the {@link ISessionsProvidersService} layer. + * + * **URI/ID scheme:** + * - **rawId** - unique session identifier (e.g. `abc123`), used as the cache key. + * - **resource** - `agent-host-{provider}:///{rawId}` (e.g. `agent-host-copilot:///abc123`). + * The scheme routes the chat service to the correct {@link AgentHostSessionHandler}. + * - **sessionId** - `local-agent-host:agent-host-{provider}:///{rawId}` — the + * provider-scoped ID used by {@link ISessionsProvider}. + */ +export class LocalAgentHostSessionsProvider extends Disposable implements ISessionsProvider { + + readonly id = LOCAL_PROVIDER_ID; + readonly label: string; + readonly icon: ThemeIcon = Codicon.vm; + readonly sessionTypes: readonly ISessionType[]; + readonly capabilities = { multipleChatsPerSession: false }; + + readonly browseActions: readonly ISessionsBrowseAction[]; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + + /** Cache of adapted sessions, keyed by raw session ID. */ + private readonly _sessionCache = new Map(); + + private _pendingSession: ISession | undefined; + private _selectedModelId: string | undefined; + private _currentNewSession: ISession | undefined; + private _currentNewSessionStatus: ISettableObservable | undefined; + + private _cacheInitialized = false; + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IFileDialogService private readonly _fileDialogService: IFileDialogService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IChatService private readonly _chatService: IChatService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + ) { + super(); + + this.label = localize('localAgentHostLabel', "Local Agent Host"); + + this.sessionTypes = [LocalAgentHostSessionType]; + + this.browseActions = [{ + label: localize('folders', "Folders"), + icon: Codicon.folderOpened, + providerId: this.id, + execute: () => this._browseForFolder(), + }]; + + // Listen for notifications from the agent host to update the session list + this._register(this._agentHostService.onDidNotification(n => { + if (n.type === 'notify/sessionAdded') { + this._handleSessionAdded(n.summary); + } else if (n.type === 'notify/sessionRemoved') { + this._handleSessionRemoved(n.session); + } + })); + + this._register(this._agentHostService.onDidAction(e => { + if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { + this._refreshSessions(); + } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { + this._handleTitleChanged(e.action.session, e.action.title); + } + })); + } + + // -- Workspaces -- + + static buildWorkspace(workingDirectory: URI): ISessionWorkspace { + const folderName = basename(workingDirectory) || workingDirectory.path; + return { + label: folderName, + icon: Codicon.folder, + repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + } + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + const folderName = basename(repositoryUri) || repositoryUri.path; + return { + label: folderName, + icon: Codicon.folder, + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + } + + // -- Sessions -- + + getSessionTypes(_sessionId: string): ISessionType[] { + return [...this.sessionTypes]; + } + + getSessions(): ISession[] { + this._ensureSessionCache(); + const sessions: ISession[] = [...this._sessionCache.values()]; + if (this._pendingSession) { + sessions.push(this._pendingSession); + } + return sessions; + } + + // -- Session Lifecycle -- + + createNewSession(workspace: ISessionWorkspace): ISession { + const workspaceUri = workspace.repositories[0]?.uri; + if (!workspaceUri) { + throw new Error('Workspace has no repository URI'); + } + + this._currentNewSession = undefined; + this._selectedModelId = undefined; + + const resource = URI.from({ scheme: sessionTypeForProvider(DEFAULT_AGENT_PROVIDER), path: `/untitled-${generateUuid()}` }); + const status = observableValue(this, SessionStatus.Untitled); + const title = observableValue(this, ''); + const updatedAt = observableValue(this, new Date()); + const changes = observableValue(this, []); + const modelId = observableValue(this, undefined); + const mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + const isArchived = observableValue(this, false); + const isRead = observableValue(this, true); + const description = observableValue(this, undefined); + const lastTurnEnd = observableValue(this, undefined); + const createdAt = new Date(); + + const mainChat: IChat = { + resource, createdAt, title, updatedAt, status, + changes, modelId, mode, isArchived, isRead, description, lastTurnEnd, + }; + + const session: ISession = { + sessionId: `${this.id}:${resource.toString()}`, + resource, + providerId: this.id, + sessionType: this.sessionTypes[0].id, + icon: Codicon.vm, + createdAt, + workspace: observableValue(this, workspace), + title, + updatedAt, + status, + changes, + modelId, + mode, + loading: observableValue(this, false), + isArchived, + isRead, + description, + lastTurnEnd, + gitHubInfo: observableValue(this, undefined), + mainChat, + chats: constObservable([mainChat]), + }; + this._currentNewSession = session; + this._currentNewSessionStatus = status; + return session; + } + + setSessionType(_sessionId: string, _type: ISessionType): ISession { + throw new Error('Local agent host sessions do not support changing session type'); + } + + setModel(sessionId: string, modelId: string): void { + if (this._currentNewSession?.sessionId === sessionId) { + this._selectedModelId = modelId; + } + } + + // -- 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 { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + await this._agentHostService.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); + this._sessionCache.delete(rawId); + this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); + } + } + + async renameChat(sessionId: string, _chatUri: URI, title: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title }; + this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq()); + } + } + + async deleteChat(_sessionId: string, _chatUri: URI): Promise { + // Agent host sessions don't support deleting individual chats + } + + setRead(sessionId: string, read: boolean): void { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached) { + cached.isRead.set(read, undefined); + } + } + + async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise { + const session = this._currentNewSession; + if (!session || session.sessionId !== chatId) { + throw new Error(`Session '${chatId}' not found or not a new session`); + } + + const { query, attachedContext } = options; + + const sessionType = session.resource.scheme; + const contribution = this._chatSessionsService.getChatSessionContribution(sessionType); + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: this._selectedModelId, + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: true, + modeInstructions: undefined, + modeId: 'agent', + applyCodeBlockSuggestionId: undefined, + permissionLevel: undefined, + }, + agentIdSilent: contribution?.type, + attachedContext, + }; + + // Open chat widget — getOrCreateChatSession will wait for the session + // handler to become available via canResolveChatSession internally. + await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); + const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[LocalAgentHost] Failed to open chat widget'); + } + + // Load session model and apply selected model + const modelRef = await this._chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None); + if (modelRef) { + if (this._selectedModelId) { + const languageModel = this._languageModelsService.lookupLanguageModel(this._selectedModelId); + if (languageModel) { + modelRef.object.inputModel.setState({ selectedModel: { identifier: this._selectedModelId, metadata: languageModel } }); + } + } + modelRef.dispose(); + } + + this._ensureSessionCache(); + const existingKeys = new Set(this._sessionCache.keys()); + + const result = await this._chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[LocalAgentHost] sendRequest rejected: ${result.reason}`); + } + + this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined); + const newSession = session; + this._pendingSession = newSession; + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); + + this._selectedModelId = undefined; + this._currentNewSessionStatus = undefined; + + try { + const committedSession = await this._waitForNewSession(existingKeys); + if (committedSession) { + this._currentNewSession = undefined; + this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); + return committedSession; + } + } catch { + // Timeout — clean up + } finally { + this._pendingSession = undefined; + } + + this._currentNewSession = undefined; + return newSession; + } + + // -- Private: Session Cache -- + + private _ensureSessionCache(): void { + if (this._cacheInitialized) { + return; + } + this._cacheInitialized = true; + this._refreshSessions(); + } + + private async _refreshSessions(): Promise { + try { + const sessions = await this._agentHostService.listSessions(); + const currentKeys = new Set(); + const added: ISession[] = []; + const changed: ISession[] = []; + + for (const meta of sessions) { + const rawId = AgentSession.id(meta.session); + const provider = AgentSession.provider(meta.session) ?? DEFAULT_AGENT_PROVIDER; + currentKeys.add(rawId); + + const existing = this._sessionCache.get(rawId); + if (existing) { + if (existing.update(meta)) { + changed.push(existing); + } + } else { + const cached = new LocalSessionAdapter(meta, this.id, sessionTypeForProvider(provider), this.sessionTypes[0].id); + this._sessionCache.set(rawId, cached); + added.push(cached); + } + } + + const removed: ISession[] = []; + for (const [key, cached] of this._sessionCache) { + if (!currentKeys.has(key)) { + this._sessionCache.delete(key); + removed.push(cached); + } + } + + if (added.length > 0 || removed.length > 0 || changed.length > 0) { + this._onDidChangeSessions.fire({ added, removed, changed }); + } + } catch { + // Agent host may not be ready yet + } + } + + private async _waitForNewSession(existingKeys: Set): Promise { + await this._refreshSessions(); + for (const [key, cached] of this._sessionCache) { + if (!existingKeys.has(key)) { + return cached; + } + } + + const waitDisposables = new DisposableStore(); + try { + const sessionPromise = new Promise((resolve) => { + waitDisposables.add(this._onDidChangeSessions.event(e => { + const newSession = e.added.find(s => { + const rawId = s.resource.path.substring(1); + return !existingKeys.has(rawId); + }); + if (newSession) { + resolve(newSession); + } + })); + }); + return await raceTimeout(sessionPromise, 30_000); + } finally { + waitDisposables.dispose(); + } + } + + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void { + const sessionUri = URI.parse(summary.resource); + const rawId = AgentSession.id(sessionUri); + if (this._sessionCache.has(rawId)) { + return; + } + + const workingDir = typeof summary.workingDirectory === 'string' + ? URI.parse(summary.workingDirectory) + : undefined; + const meta: IAgentSessionMetadata = { + session: sessionUri, + startTime: summary.createdAt, + modifiedTime: summary.modifiedAt, + summary: summary.title, + workingDirectory: workingDir, + }; + const provider = AgentSession.provider(sessionUri) ?? DEFAULT_AGENT_PROVIDER; + const cached = new LocalSessionAdapter(meta, this.id, sessionTypeForProvider(provider), this.sessionTypes[0].id); + this._sessionCache.set(rawId, cached); + this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] }); + } + + private _handleSessionRemoved(session: URI | string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + this._sessionCache.delete(rawId); + this._onDidChangeSessions.fire({ added: [], removed: [cached], changed: [] }); + } + } + + private _handleTitleChanged(session: string, title: string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _rawIdFromChatId(chatId: string): string | undefined { + const prefix = `${this.id}:`; + const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; + try { + return URI.parse(resourceStr).path.substring(1) || undefined; + } catch { + return undefined; + } + } + + // -- Private: Browse -- + + private async _browseForFolder(): Promise { + try { + const selected = await this._fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectLocalFolder', "Select Folder"), + }); + if (selected?.[0]) { + return this.resolveWorkspace(selected[0]); + } + } catch { + // dialog was cancelled or failed + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts new file mode 100644 index 0000000000000..48110c4d9a2e6 --- /dev/null +++ b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -0,0 +1,480 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AgentSession, IAgentHostService, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import type { ISessionAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; +import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService, type ChatSendResult } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ISessionChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; + +import { SessionStatus } from '../../../sessions/common/sessionData.js'; +import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; + +// ---- Mock IAgentHostService ------------------------------------------------- + +class MockAgentHostService extends mock() { + declare readonly _serviceBrand: undefined; + + private readonly _onDidAction = new Emitter(); + override readonly onDidAction = this._onDidAction.event; + private readonly _onDidNotification = new Emitter(); + override readonly onDidNotification = this._onDidNotification.event; + + override readonly clientId = 'test-local-client'; + private readonly _sessions = new Map(); + public disposedSessions: URI[] = []; + public dispatchedActions: { action: ISessionAction; clientId: string; clientSeq: number }[] = []; + + private _nextSeq = 0; + + override nextClientSeq(): number { + return this._nextSeq++; + } + + override async listSessions(): Promise { + return [...this._sessions.values()]; + } + + override async disposeSession(session: URI): Promise { + this.disposedSessions.push(session); + const rawId = AgentSession.id(session); + this._sessions.delete(rawId); + } + + override dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this.dispatchedActions.push({ action, clientId, clientSeq }); + } + + // Test helpers + addSession(meta: IAgentSessionMetadata): void { + this._sessions.set(AgentSession.id(meta.session), meta); + } + + fireNotification(n: INotification): void { + this._onDidNotification.fire(n); + } + + fireAction(envelope: IActionEnvelope): void { + this._onDidAction.fire(envelope); + } + + dispose(): void { + this._onDidAction.dispose(); + this._onDidNotification.dispose(); + } +} + +// ---- Test helpers ----------------------------------------------------------- + +function createSession(id: string, opts?: { provider?: string; summary?: string; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { + return { + session: AgentSession.uri(opts?.provider ?? 'copilot', id), + startTime: opts?.startTime ?? 1000, + modifiedTime: opts?.modifiedTime ?? 2000, + summary: opts?.summary, + workingDirectory: opts?.workingDirectory, + }; +} + +function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService): LocalAgentHostSessionsProvider { + const instantiationService = disposables.add(new TestInstantiationService()); + + instantiationService.stub(IAgentHostService, agentHostService); + instantiationService.stub(IFileDialogService, {}); + instantiationService.stub(IChatSessionsService, { + getChatSessionContribution: () => ({ type: 'agent-host-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), + getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), + }); + instantiationService.stub(IChatService, { + acquireOrLoadSession: async () => undefined, + sendRequest: async (): Promise => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }), + }); + instantiationService.stub(IChatWidgetService, { + openSession: async () => undefined, + }); + instantiationService.stub(ILanguageModelsService, { + lookupLanguageModel: () => undefined, + }); + + return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider)); +} + +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; workingDirectory?: string }): void { + const provider = opts?.provider ?? 'copilot'; + const sessionUri = AgentSession.uri(provider, rawId); + agentHost.fireNotification({ + type: NotificationType.SessionAdded, + summary: { + resource: sessionUri.toString(), + provider, + title: opts?.title ?? `Session ${rawId}`, + status: ProtocolSessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: opts?.workingDirectory, + }, + }); +} + +function fireSessionRemoved(agentHost: MockAgentHostService, rawId: string, provider = 'copilot'): void { + const sessionUri = AgentSession.uri(provider, rawId); + agentHost.fireNotification({ + type: NotificationType.SessionRemoved, + session: sessionUri.toString(), + }); +} + +suite('LocalAgentHostSessionsProvider', () => { + const disposables = new DisposableStore(); + let agentHost: MockAgentHostService; + + setup(() => { + agentHost = new MockAgentHostService(); + disposables.add(toDisposable(() => agentHost.dispose())); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Provider identity ------- + + test('has correct id, label, and sessionType', () => { + const provider = createProvider(disposables, agentHost); + + assert.strictEqual(provider.id, 'local-agent-host'); + assert.ok(provider.label.length > 0); + assert.strictEqual(provider.sessionTypes.length, 1); + assert.strictEqual(provider.sessionTypes[0].id, 'agent-host-copilot'); + }); + + // ---- Workspace resolution ------- + + test('resolveWorkspace builds workspace from URI', () => { + const provider = createProvider(disposables, agentHost); + const uri = URI.parse('file:///home/user/project'); + const ws = provider.resolveWorkspace(uri); + + assert.strictEqual(ws.label, 'project'); + assert.strictEqual(ws.repositories.length, 1); + assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); + assert.strictEqual(ws.requiresWorkspaceTrust, true); + }); + + // ---- Browse actions ------- + + test('has one browse action for local folders', () => { + const provider = createProvider(disposables, agentHost); + + assert.strictEqual(provider.browseActions.length, 1); + assert.strictEqual(provider.browseActions[0].providerId, provider.id); + }); + + // ---- Session listing via notifications ------- + + test('onDidChangeSessions fires when session added notification arrives', () => { + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + fireSessionAdded(agentHost, 'notif-1', { title: 'Notif Session' }); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].added.length, 1); + assert.strictEqual(changes[0].added[0].title.get(), 'Notif Session'); + }); + + test('session removed notification removes from cache', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'to-remove', { title: 'Removed' }); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + fireSessionRemoved(agentHost, 'to-remove'); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].removed.length, 1); + }); + + test('duplicate session added notification is ignored', () => { + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); + + assert.strictEqual(changes.length, 1); + }); + + test('removing non-existent session is no-op', () => { + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + fireSessionRemoved(agentHost, 'does-not-exist'); + + assert.strictEqual(changes.length, 0); + }); + + // ---- Session listing via refresh ------- + + test('getSessions populates from listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('list-1', { summary: 'First' })); + agentHost.addSession(createSession('list-2', { summary: 'Second' })); + + const provider = createProvider(disposables, agentHost); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + provider.getSessions(); + await timeout(0); + + assert.ok(changes.length > 0); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + })); + + // ---- Session lifecycle ------- + + test('createNewSession returns session with correct fields', () => { + const provider = createProvider(disposables, agentHost); + const workspace = { + label: 'my-project', + icon: { id: 'folder' }, + repositories: [{ uri: URI.parse('file:///home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + + const session = provider.createNewSession(workspace); + + assert.strictEqual(session.providerId, provider.id); + assert.strictEqual(session.status.get(), SessionStatus.Untitled); + assert.ok(session.workspace.get()); + assert.strictEqual(session.workspace.get()?.label, 'my-project'); + assert.strictEqual(session.sessionType, provider.sessionTypes[0].id); + }); + + test('createNewSession throws when no repository URI', () => { + const provider = createProvider(disposables, agentHost); + const workspace = { label: 'empty', icon: { id: 'folder' }, repositories: [], requiresWorkspaceTrust: false }; + + assert.throws(() => provider.createNewSession(workspace), /Workspace has no repository URI/); + }); + + // ---- Session actions ------- + + test('deleteSession calls disposeSession and removes from cache', async () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'del-sess', { title: 'To Delete' }); + + const sessions = provider.getSessions(); + const target = sessions.find(s => s.title.get() === 'To Delete'); + assert.ok(target); + + await provider.deleteSession(target!.sessionId); + + assert.strictEqual(agentHost.disposedSessions.length, 1); + const disposedUri = agentHost.disposedSessions[0]; + assert.strictEqual(AgentSession.provider(disposedUri), 'copilot'); + assert.strictEqual(AgentSession.id(disposedUri), 'del-sess'); + assert.strictEqual(provider.getSessions().find(s => s.title.get() === 'To Delete'), undefined); + }); + + test('setRead toggles read state locally', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'read-sess', { title: 'Read Test' }); + + const sessions = provider.getSessions(); + const target = sessions.find(s => s.title.get() === 'Read Test'); + assert.ok(target); + + assert.strictEqual(target!.isRead.get(), true); + provider.setRead(target!.sessionId, false); + assert.strictEqual(target!.isRead.get(), false); + }); + + // ---- Rename ------- + + test('renameChat dispatches SessionTitleChanged action', async () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'rename-sess', { title: 'Old Title' }); + + const sessions = provider.getSessions(); + const target = sessions.find(s => s.title.get() === 'Old Title'); + assert.ok(target); + + await provider.renameChat(target!.sessionId, target!.resource, 'New Title'); + + assert.strictEqual(agentHost.dispatchedActions.length, 1); + const dispatched = agentHost.dispatchedActions[0]; + assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged); + assert.strictEqual((dispatched.action as { title: string }).title, 'New Title'); + const actionSession = (dispatched.action as { session: string }).session; + assert.strictEqual(AgentSession.provider(actionSession), 'copilot'); + assert.strictEqual(AgentSession.id(actionSession), 'rename-sess'); + assert.strictEqual(dispatched.clientId, 'test-local-client'); + }); + + test('renameChat updates local title optimistically', async () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'rename-opt', { title: 'Before' }); + + const sessions = provider.getSessions(); + const target = sessions.find(s => s.title.get() === 'Before'); + assert.ok(target); + + await provider.renameChat(target!.sessionId, target!.resource, 'After'); + assert.strictEqual(target!.title.get(), 'After'); + }); + + test('renameChat is no-op for unknown session', async () => { + const provider = createProvider(disposables, agentHost); + await provider.renameChat('nonexistent-id', URI.parse('test://nonexistent'), 'Ignored'); + + assert.strictEqual(agentHost.dispatchedActions.length, 0); + }); + + // ---- Title change from server ------- + + test('server-echoed SessionTitleChanged updates cached title', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'echo-sess', { title: 'Original' }); + + const sessions = provider.getSessions(); + const target = sessions.find(s => s.title.get() === 'Original'); + assert.ok(target); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + agentHost.fireAction({ + action: { + type: ActionType.SessionTitleChanged, + session: AgentSession.uri('copilot', 'echo-sess').toString(), + title: 'Server Title', + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + assert.strictEqual(target!.title.get(), 'Server Title'); + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].changed.length, 1); + }); + + // ---- Refresh on turnComplete ------- + + test('turnComplete action triggers session refresh', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('turn-sess', { summary: 'Before', modifiedTime: 1000 })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + // Update on connection side + agentHost.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 })); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + agentHost.fireAction({ + action: { + type: 'session/turnComplete', + session: AgentSession.uri('copilot', 'turn-sess').toString(), + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + await timeout(0); + + assert.ok(changes.length > 0); + const updatedSession = provider.getSessions().find(s => s.title.get() === 'After'); + assert.ok(updatedSession); + })); + + // ---- getSessionTypes ------- + + test('getSessionTypes returns available types', () => { + const provider = createProvider(disposables, agentHost); + const types = provider.getSessionTypes('any-id'); + + assert.strictEqual(types.length, 1); + assert.strictEqual(types[0].id, 'agent-host-copilot'); + }); + + // ---- Session data adapter ------- + + test('session adapter has correct workspace from working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('file:///home/user/myrepo') })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const wsSession = sessions.find(s => s.title.get() === 'WS Test'); + assert.ok(wsSession); + + const workspace = wsSession!.workspace.get(); + assert.ok(workspace); + assert.strictEqual(workspace!.label, 'myrepo'); + })); + + test('session adapter without working directory has no workspace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('no-ws-sess', { summary: 'No WS' })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const session = sessions.find(s => s.title.get() === 'No WS'); + assert.ok(session); + assert.strictEqual(session!.workspace.get(), undefined); + })); + + test('session adapter uses raw ID as fallback title', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('abcdef1234567890')); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const session = sessions[0]; + assert.ok(session); + assert.strictEqual(session.title.get(), 'Session abcdef12'); + })); + + // ---- sendAndCreateChat ------- + + test('sendAndCreateChat throws for unknown session', async () => { + const provider = createProvider(disposables, agentHost); + await assert.rejects( + () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + /not found or not a new session/, + ); + }); +}); 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/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts new file mode 100644 index 0000000000000..62e3fd92a9a3c --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -0,0 +1,604 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { observableValue } from '../../../../base/common/observable.js'; +import { isEqualOrParent } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import * as nls from '../../../../nls.js'; +import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.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 ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.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 { LoggingAgentConnection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IAgentHostFileSystemService } from '../../../../workbench/services/agentHost/common/agentHostFileSystemService.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 { createRemoteAgentHarnessDescriptor, RemoteAgentCustomizationItemProvider } from './remoteAgentHostCustomizationHarness.js'; +import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; +import { SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; +import { ISSHRemoteAgentHostService } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; + +/** 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, () => loggedConnection.nextClientSeq())); + 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 and language model provider. + * + * Uses the same unified {@link AgentHostSessionHandler} 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()); + + /** Per-address sessions providers, registered for all configured entries. */ + private readonly _providerStores = this._register(new DisposableMap()); + private readonly _providerInstances = new Map(); + private readonly _pendingSSHReconnects = new Set(); + + 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, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAgentHostFileSystemService private readonly _agentHostFileSystemService: IAgentHostFileSystemService, + @ISSHRemoteAgentHostService private readonly _sshService: ISSHRemoteAgentHostService, + @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, + @IStorageService private readonly _storageService: IStorageService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, + ) { + super(); + + // Reconcile providers when configured entries change + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(RemoteAgentHostsSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + this._reconcile(); + } + })); + + // Reconcile when connections change (added/removed/reconnected) + this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._reconcile(); + })); + + // 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 configured entries and connected remotes + this._reconcile(); + } + + private _reconcile(): void { + this._reconcileProviders(); + this._reconcileConnections(); + this._reconnectSSHEntries(); + + // Ensure every live connection is wired to its provider. + // This covers the case where a provider was recreated (e.g. name + // change) while a connection for that address already existed. + for (const [address, connState] of this._connections) { + const provider = this._providerInstances.get(address); + if (provider) { + const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); + provider.setConnection(connState.loggedConnection, connectionInfo?.defaultDirectory); + } + } + + // Update connection status on all providers (including those + // that are reconnecting and don't have an active connection). + for (const [address, provider] of this._providerInstances) { + const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); + if (connectionInfo) { + provider.setConnectionStatus(connectionInfo.status); + } else { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); + } + } + } + + private _reconcileProviders(): void { + const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); + const entries = enabled ? this._remoteAgentHostService.configuredEntries : []; + const desiredAddresses = new Set(entries.map(e => e.address)); + + // Remove providers no longer configured + for (const [address] of this._providerStores) { + if (!desiredAddresses.has(address)) { + this._providerStores.deleteAndDispose(address); + } + } + + // Add or recreate providers for configured entries + for (const entry of entries) { + const existing = this._providerInstances.get(entry.address); + if (existing && existing.label !== (entry.name || entry.address)) { + // Name changed — recreate since ISessionsProvider.label is readonly + this._providerStores.deleteAndDispose(entry.address); + } + if (!this._providerStores.has(entry.address)) { + this._createProvider(entry); + } + } + } + + private _createProvider(entry: IRemoteAgentHostEntry): void { + const store = new DisposableStore(); + const provider = this._instantiationService.createInstance( + RemoteAgentHostSessionsProvider, { address: entry.address, name: entry.name }); + store.add(provider); + store.add(this._sessionsProvidersService.registerProvider(provider)); + this._providerInstances.set(entry.address, provider); + store.add(toDisposable(() => this._providerInstances.delete(entry.address))); + this._providerStores.set(entry.address, store); + } + + /** + * Re-establish SSH connections for configured entries that have an + * sshConfigHost but no active connection. + */ + private _reconnectSSHEntries(): void { + const entries = this._remoteAgentHostService.configuredEntries; + for (const entry of entries) { + if (!entry.sshConfigHost) { + continue; + } + // Skip if already connected or reconnecting + const hasConnection = this._remoteAgentHostService.connections.some( + c => c.address === entry.address && c.status === RemoteAgentHostConnectionStatus.Connected + ); + if (hasConnection || this._pendingSSHReconnects.has(entry.sshConfigHost)) { + continue; + } + this._pendingSSHReconnects.add(entry.sshConfigHost); + this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${entry.sshConfigHost}`); + this._sshService.reconnect(entry.sshConfigHost, entry.name).then(() => { + this._pendingSSHReconnects.delete(entry.sshConfigHost!); + this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${entry.sshConfigHost}`); + }).catch(err => { + this._pendingSSHReconnects.delete(entry.sshConfigHost!); + this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${entry.sshConfigHost}`, err); + }); + } + } + + private _reconcileConnections(): void { + const currentConnections = this._remoteAgentHostService.connections; + const connectedAddresses = new Set( + currentConnections + .filter(c => c.status === RemoteAgentHostConnectionStatus.Connected) + .map(c => c.address) + ); + const allAddresses = new Set(currentConnections.map(c => c.address)); + + // Remove contribution state for connections that are no longer present at all + for (const [address] of this._connections) { + if (!allAddresses.has(address)) { + this._logService.info(`[RemoteAgentHost] Removing contribution for ${address}`); + this._providerInstances.get(address)?.clearConnection(); + this._connections.deleteAndDispose(address); + } else if (!connectedAddresses.has(address)) { + // Connection exists but is not connected (reconnecting or disconnected). + // Keep the contribution state but don't clear the provider — + // the session cache is preserved during reconnect. + } + } + + // Add or update connections + for (const connectionInfo of currentConnections) { + // Only set up contribution state for connected entries + if (connectionInfo.status !== RemoteAgentHostConnectionStatus.Connected) { + continue; + } + const existing = this._connections.get(connectionInfo.address); + if (existing) { + // If the name or clientId changed, tear down and re-register + if (existing.name !== connectionInfo.name || existing.clientState.clientId !== connectionInfo.clientId) { + this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}`); + this._connections.deleteAndDispose(connectionInfo.address); + this._setupConnection(connectionInfo); + } + } else { + this._setupConnection(connectionInfo); + } + } + } + + private _setupConnection(connectionInfo: IRemoteAgentHostConnectionInfo): void { + const connection = this._remoteAgentHostService.getConnection(connectionInfo.address); + if (!connection) { + return; + } + + const { address, name } = connectionInfo; + 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._agentHostFileSystemService.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 and refresh models afterward + this._authenticateWithConnection(loggedConnection).then(() => loggedConnection.refreshModels()).catch(() => { /* best-effort */ }); + + // Wire connection to existing sessions provider + this._providerInstances.get(address)?.setConnection(loggedConnection, connectionInfo.defaultDirectory); + + // Expose the output channel ID so the workspace picker can offer "Show Output" + this._providerInstances.get(address)?.setOutputChannelId(channelId); + } + + 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): URI | 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) { + sessionWorkingDirs.set(resourceKey, repoUri); + return repoUri; + } + return undefined; + }; + + // Chat session contribution + agentStore.add(this._chatSessionsService.registerChatSessionContribution({ + type: sessionType, + name: agentId, + displayName, + description: agent.description, + canDelegate: true, + requiresCustomModels: true, + supportsDelegation: false, + capabilities: { + supportsCheckpoints: true, + }, + })); + + // Customization harness for this remote agent + const itemProvider = agentStore.add(new RemoteAgentCustomizationItemProvider(agent, connState.clientState)); + const syncProvider = agentStore.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); + const harnessDescriptor = createRemoteAgentHarnessDescriptor(sessionType, displayName, itemProvider, syncProvider); + agentStore.add(this._customizationHarnessService.registerExternalHarness(harnessDescriptor)); + + // Bundler for packaging individual files into a virtual Open Plugin + const bundler = agentStore.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); + + // Agent-level customizations observable + const customizations = observableValue('agentCustomizations', []); + const updateCustomizations = async () => { + const refs = await this._resolveCustomizations(syncProvider, bundler); + customizations.set(refs, undefined); + }; + agentStore.add(syncProvider.onDidChange(() => updateCustomizations())); + updateCustomizations(); // resolve initial state + + // 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), + customizations, + })); + 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}`); + } + + /** + * Resolves the customizations to include in the active client set. + * + * Entries are classified as either: + * - **Plugin**: A selected URI matches an installed plugin's root URI. + * - **Individual file**: All other selected files are bundled into a + * synthetic Open Plugin via {@link SyncedCustomizationBundler}. + */ + private async _resolveCustomizations( + syncProvider: AgentCustomizationSyncProvider, + bundler: SyncedCustomizationBundler, + ): Promise { + const entries = syncProvider.getSelectedEntries(); + if (entries.length === 0) { + return []; + } + + const plugins = this._agentPluginService.plugins.get(); + const refs: ICustomizationRef[] = []; + const individualFiles: { uri: URI; type: PromptsType }[] = []; + + for (const entry of entries) { + const plugin = plugins.find(p => isEqualOrParent(entry.uri, p.uri)); + if (plugin) { + refs.push({ + uri: plugin.uri.toString() as ProtocolURI, + displayName: plugin.label, + }); + } else if (entry.type) { + individualFiles.push({ uri: entry.uri, type: entry.type }); + } + } + + if (individualFiles.length > 0) { + const result = await bundler.bundle(individualFiles); + if (result) { + refs.push(result.ref); + } + } + + return refs; + } + + private _authenticateAllConnections(): void { + for (const [, connState] of this._connections) { + this._authenticateWithConnection(connState.loggedConnection).then(() => connState.loggedConnection.refreshModels()).catch(() => { /* best-effort */ }); + } + } + + /** + * 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, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, + 'chat.sshRemoteAgentHostCommand': { + type: 'string', + description: nls.localize('chat.sshRemoteAgentHostCommand', "For development: Override the command used to start the remote agent host over SSH. When set, skips automatic CLI installation and runs this command instead. The command must print a WebSocket URL matching ws://127.0.0.1:PORT (optionally with ?tkn=TOKEN) to stdout or stderr./"), + default: '', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, + [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.") }, + sshConfigHost: { type: 'string', description: nls.localize('chat.remoteAgentHosts.sshConfigHost', "SSH config host alias for automatic reconnection via SSH tunnel.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts new file mode 100644 index 0000000000000..e95b8c52b0f5e --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -0,0 +1,470 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.remoteAgentHost.add', + title: localize2('addRemoteAgentHost', "Add Remote Agent Host..."), + category: SessionsCategories.Sessions, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const remoteAgentHostService = accessor.get(IRemoteAgentHostService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + + // Prompt for address + const address = await quickInputService.input({ + title: localize('addRemoteTitle', "Add Remote Agent Host"), + 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 (!address) { + return; + } + const parsed = parseRemoteAgentHostInput(address); + if (!parsed.parsed) { + return; + } + + // Prompt for display name + const defaultName = parsed.parsed.suggestedName; + const name = await quickInputService.input({ + title: localize('nameRemoteTitle', "Name Remote Agent Host"), + 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."), + }); + if (!name?.trim()) { + return; + } + + // Connect + try { + await remoteAgentHostService.addRemoteAgentHost({ + address: parsed.parsed.address, + name: name.trim(), + connectionToken: parsed.parsed.connectionToken, + }); + } catch { + notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.parsed.address)); + } + } +}); + +// ---- Connect via SSH ------------------------------------------------------- + +interface ISSHAuthMethodPickItem extends IQuickPickItem { + readonly method: SSHAuthMethod; +} + +interface ISSHHostPickItem extends IQuickPickItem { + readonly hostAlias?: string; +} + +async function promptToConnectViaSSH( + accessor: ServicesAccessor, +): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + const instantiationService = accessor.get(IInstantiationService); + + let host: string; + let username: string | undefined; + let port: number | undefined; + let resolvedConfig: ISSHResolvedConfig | undefined; + let suggestedName: string | undefined; + let defaultAuthMethod: SSHAuthMethod | undefined; + let defaultKeyPath: string | undefined; + + const configHosts = await sshService.listSSHConfigHosts().catch(() => [] as string[]); + if (configHosts.length > 0) { + const hostPicks: ISSHHostPickItem[] = configHosts.map(h => ({ + label: h, + hostAlias: h, + })); + hostPicks.push({ + label: localize('sshEnterManually', "Enter Manually..."), + description: localize('sshEnterManuallyDesc', "Type in host, username, and port"), + }); + + const picked = await quickInputService.pick(hostPicks, { + title: localize('sshHostTitle', "Connect via SSH"), + placeHolder: localize('sshPickHostPlaceholder', "Select an SSH host or enter manually"), + }); + if (!picked) { + return; + } + + if (picked.hostAlias) { + try { + resolvedConfig = await sshService.resolveSSHConfig(picked.hostAlias); + } catch (err) { + notificationService.error(localize('sshResolveConfigFailed', "Failed to resolve SSH config for {0}: {1}", picked.hostAlias, String(err))); + return; + } + + host = resolvedConfig.hostname; + username = resolvedConfig.user; + port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined; + suggestedName = picked.hostAlias; + + // Determine auth method from resolved config + if (resolvedConfig.identityFile.length > 0) { + const firstKey = resolvedConfig.identityFile[0]; + const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss']; + if (!defaultKeys.includes(firstKey)) { + defaultAuthMethod = SSHAuthMethod.KeyFile; + defaultKeyPath = firstKey; + } + } + // If no explicit key, default to SSH agent + if (!defaultAuthMethod) { + defaultAuthMethod = SSHAuthMethod.Agent; + } + + // Config host has enough info — connect directly, skip all prompts + if (username) { + const config: ISSHAgentHostConfig = { + host, + port, + username, + authMethod: defaultAuthMethod, + privateKeyPath: defaultKeyPath, + name: suggestedName, + sshConfigHost: picked.hostAlias, + }; + const connection = await instantiationService.invokeFunction(accessor => + connectWithProgress(accessor, config, suggestedName!) + ); + if (connection) { + await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection)); + } + return; + } + } else { + const manualResult = await promptForManualHost(quickInputService); + if (!manualResult) { + return; + } + host = manualResult.host; + username = manualResult.username; + port = manualResult.port; + } + } else { + const manualResult = await promptForManualHost(quickInputService); + if (!manualResult) { + return; + } + host = manualResult.host; + username = manualResult.username; + port = manualResult.port; + } + + if (!username) { + const usernameInput = await quickInputService.input({ + title: localize('sshUsernameTitle', "SSH Username"), + prompt: localize('sshUsernamePrompt', "Enter the username for {0}.", host), + placeHolder: 'root', + ignoreFocusLost: true, + validateInput: async value => value.trim() ? undefined : localize('sshUsernameEmpty', "Enter a username."), + }); + if (!usernameInput) { + return; + } + username = usernameInput.trim(); + } + + const authPicks: ISSHAuthMethodPickItem[] = [ + { + method: SSHAuthMethod.Agent, + label: localize('sshAuthAgent', "SSH Agent"), + description: localize('sshAuthAgentDesc', "Use the running SSH agent for authentication"), + }, + { + method: SSHAuthMethod.KeyFile, + label: localize('sshAuthKey', "Private Key File"), + description: localize('sshAuthKeyDesc', "Authenticate with a private key file"), + }, + { + method: SSHAuthMethod.Password, + label: localize('sshAuthPassword', "Password"), + description: localize('sshAuthPasswordDesc', "Authenticate with a password"), + }, + ]; + + let authMethod: SSHAuthMethod; + if (defaultAuthMethod) { + authMethod = defaultAuthMethod; + } else { + const authPicked = await quickInputService.pick(authPicks, { + title: localize('sshAuthTitle', "Authentication Method"), + placeHolder: localize('sshAuthPlaceholder', "Choose how to authenticate with {0}", host), + }); + if (!authPicked) { + return; + } + authMethod = authPicked.method; + } + + let privateKeyPath: string | undefined; + let password: string | undefined; + + if (authMethod === SSHAuthMethod.KeyFile) { + const keyPath = await quickInputService.input({ + title: localize('sshKeyTitle', "Private Key Path"), + prompt: localize('sshKeyPrompt', "Enter the path to your SSH private key."), + placeHolder: '~/.ssh/id_rsa', + value: defaultKeyPath ?? '~/.ssh/id_rsa', + ignoreFocusLost: true, + validateInput: async value => value.trim() ? undefined : localize('sshKeyEmpty', "Enter a key file path."), + }); + if (!keyPath) { + return; + } + privateKeyPath = keyPath.trim(); + } else if (authMethod === SSHAuthMethod.Password) { + const pw = await quickInputService.input({ + title: localize('sshPasswordTitle', "SSH Password"), + prompt: localize('sshPasswordPrompt', "Enter the password for {0}@{1}.", username, host), + password: true, + ignoreFocusLost: true, + validateInput: async value => value ? undefined : localize('sshPasswordEmpty', "Enter a password."), + }); + if (!pw) { + return; + } + password = pw; + } + + const defaultName = suggestedName ?? `${username}@${host}`; + const name = await quickInputService.input({ + title: localize('sshNameTitle', "Name Remote"), + prompt: localize('sshNamePrompt', "Enter a display name for this SSH remote."), + placeHolder: localize('sshNamePlaceholder', "My Remote"), + value: defaultName, + valueSelection: [0, defaultName.length], + ignoreFocusLost: true, + validateInput: async value => value.trim() ? undefined : localize('sshNameEmpty', "Enter a name."), + }); + if (!name) { + return; + } + + const config: ISSHAgentHostConfig = { + host, + port, + username, + authMethod, + privateKeyPath, + password, + name: name.trim(), + }; + + const connection = await instantiationService.invokeFunction(accessor => + connectWithProgress(accessor, config, host) + ); + if (connection) { + await instantiationService.invokeFunction(accessor => promptForRemoteFolder(accessor, connection)); + } +} + +async function connectWithProgress( + accessor: ServicesAccessor, + config: ISSHAgentHostConfig, + displayHost: string, +): Promise { + const sshService = accessor.get(ISSHRemoteAgentHostService); + const notificationService = accessor.get(INotificationService); + + const handle = notificationService.notify({ + severity: Severity.Info, + message: localize('sshConnecting', "Connecting to {0} via SSH...", displayHost), + progress: { infinite: true }, + }); + + // Build the expected connection key to filter progress events. + // Must match the key logic in the shared process service. + const expectedKey = config.sshConfigHost + ? `ssh:${config.sshConfigHost}` + : `${config.username}@${config.host}:${config.port ?? 22}`; + + const progressListener = sshService.onDidReportConnectProgress?.(progress => { + if (progress.connectionKey === expectedKey) { + handle.updateMessage(progress.message); + } + }); + + try { + const connection = await sshService.connect(config); + handle.close(); + return connection; + } catch (err) { + handle.close(); + notificationService.error(localize('sshConnectFailed', "Failed to connect via SSH to {0}: {1}", displayHost, String(err))); + return undefined; + } finally { + progressListener?.dispose(); + } +} + +/** + * After a successful SSH connection, show the remote folder picker and + * pre-select the chosen folder in the workspace picker. + */ +async function promptForRemoteFolder( + accessor: ServicesAccessor, + connection: ISSHAgentHostConnection, +): Promise { + const viewsService = accessor.get(IViewsService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + + // The provider is created synchronously during addSSHConnection's + // onDidChangeConnections event, so it should exist by now. + const provider = sessionsProvidersService.getProviders().find(p => p.remoteAddress === connection.localAddress); + if (!provider) { + return; + } + + // Use the provider's existing browse action to show the folder picker + const browseAction = provider.browseActions[0]; + if (!browseAction) { + return; + } + + const workspace = await browseAction.execute(); + if (!workspace) { + return; + } + + sessionsManagementService.openNewSessionView(); + const view = await viewsService.openView(SessionsViewId, true); + view?.selectWorkspace({ providerId: provider.id, workspace }); +} + +async function promptForManualHost( + quickInputService: IQuickInputService, +): Promise<{ host: string; username: string | undefined; port: number | undefined } | undefined> { + const validateSshHostInput = (value: string): string | undefined => { + const v = value.trim(); + if (!v) { + return localize('sshHostEmpty', "Enter an SSH host."); + } + const atIdx = v.indexOf('@'); + if (atIdx === 0) { + return localize('sshUsernameMissingInHost', "Enter a username before '@'."); + } + if (atIdx === v.length - 1) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + const hostPart = atIdx !== -1 ? v.substring(atIdx + 1) : v; + if (!hostPart) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + const colonIdx = hostPart.lastIndexOf(':'); + if (colonIdx !== -1) { + const hostName = hostPart.substring(0, colonIdx); + const portStr = hostPart.substring(colonIdx + 1); + if (!hostName) { + return localize('sshHostMissingAfterAt', "Enter a host name after '@'."); + } + if (portStr) { + const portNum = Number(portStr); + if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { + return localize('sshHostInvalidPort', "Enter a valid port number."); + } + } + } + return undefined; + }; + + const hostInput = await quickInputService.input({ + title: localize('sshManualHostTitle', "Connect via SSH"), + prompt: localize('sshHostPrompt', "Enter the SSH host (e.g. user@hostname or user@hostname:port)."), + placeHolder: 'user@myserver.example.com', + ignoreFocusLost: true, + validateInput: async value => validateSshHostInput(value), + }); + if (!hostInput) { + return undefined; + } + + const trimmed = hostInput.trim(); + let username: string | undefined; + let host: string; + let port: number | undefined; + const atIndex = trimmed.indexOf('@'); + + let hostPart: string; + if (atIndex !== -1) { + username = trimmed.substring(0, atIndex); + hostPart = trimmed.substring(atIndex + 1); + } else { + hostPart = trimmed; + } + + const colonIndex = hostPart.lastIndexOf(':'); + if (colonIndex !== -1) { + host = hostPart.substring(0, colonIndex); + const portStr = hostPart.substring(colonIndex + 1); + if (portStr) { + port = Number(portStr); + } + } else { + host = hostPart; + } + + return { host, username, port }; +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.sessions.connectViaSSH', + title: localize2('connectViaSSH', "Connect to Remote Agent Host via SSH"), + category: SessionsCategories.Sessions, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + await promptToConnectViaSSH(accessor); + } +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts new file mode 100644 index 0000000000000..9da1eaba4e586 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.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. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { AICustomizationManagementSection, type IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { type IHarnessDescriptor, type IExternalCustomizationItem, type IExternalCustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; +import { type IAgentInfo, type ICustomizationRef, type ISessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; +import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; + +export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; + +/** + * Maps a {@link CustomizationStatus} enum value to the string literal + * expected by {@link IExternalCustomizationItem.status}. + */ +function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { + switch (status) { + case CustomizationStatus.Loading: return 'loading'; + case CustomizationStatus.Loaded: return 'loaded'; + case CustomizationStatus.Degraded: return 'degraded'; + case CustomizationStatus.Error: return 'error'; + default: return undefined; + } +} + +/** + * Provider that exposes a remote agent's customizations as + * {@link IExternalCustomizationItem} entries for the list widget. + * + * Baseline items come from {@link IAgentInfo.customizations} (available + * without an active session). When a session is active, the provider + * overlays {@link ISessionCustomization} data, which includes loading + * status and enabled state. + */ +export class RemoteAgentCustomizationItemProvider extends Disposable implements IExternalCustomizationItemProvider { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _agentCustomizations: readonly ICustomizationRef[]; + private _sessionCustomizations: readonly ISessionCustomization[] | undefined; + + constructor( + agentInfo: IAgentInfo, + private readonly _clientState: SessionClientState, + ) { + super(); + this._agentCustomizations = agentInfo.customizations ?? []; + + // Listen for session state changes that include customization updates + this._register(this._clientState.onDidChangeSessionState(({ state }) => { + if (state.customizations !== this._sessionCustomizations) { + this._sessionCustomizations = state.customizations; + this._onDidChange.fire(); + } + })); + } + + /** + * Updates the baseline agent customizations (e.g. when root state + * changes and agent info is refreshed). + */ + updateAgentCustomizations(customizations: readonly ICustomizationRef[]): void { + this._agentCustomizations = customizations; + this._onDidChange.fire(); + } + + async provideChatSessionCustomizations(_token: CancellationToken): Promise { + // When a session is active, prefer session-level data (includes status) + if (this._sessionCustomizations) { + return this._sessionCustomizations.map(sc => ({ + uri: URI.isUri(sc.customization.uri) ? sc.customization.uri : URI.parse(sc.customization.uri), + type: 'plugin', + name: sc.customization.displayName, + description: sc.customization.description, + status: toStatusString(sc.status), + statusMessage: sc.statusMessage, + enabled: sc.enabled, + })); + } + + // Baseline: agent-level customizations (no status info) + return this._agentCustomizations.map(ref => ({ + uri: URI.isUri(ref.uri) ? ref.uri : URI.parse(ref.uri as unknown as string), + type: 'plugin', + name: ref.displayName, + description: ref.description, + })); + } +} + +/** + * Creates a {@link IHarnessDescriptor} for a remote agent discovered via + * the agent host protocol. + * + * The descriptor exposes the agent's server-provided customizations through + * an {@link IExternalCustomizationItemProvider} and allows the user to + * select local customizations for syncing via an {@link ICustomizationSyncProvider}. + */ +export function createRemoteAgentHarnessDescriptor( + harnessId: string, + displayName: string, + itemProvider: RemoteAgentCustomizationItemProvider, + syncProvider: AgentCustomizationSyncProvider, +): IHarnessDescriptor { + const allSources = [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE]; + const filter: IStorageSourceFilter = { sources: allSources }; + + return { + id: harnessId, + label: displayName, + icon: ThemeIcon.fromId(Codicon.remote.id), + hiddenSections: [ + AICustomizationManagementSection.Models, + AICustomizationManagementSection.McpServers, + ], + hideGenerateButton: true, + getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter { + return filter; + }, + itemProvider, + syncProvider, + }; +} 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..7cca78452546b --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -0,0 +1,835 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { basename } from '../../../../base/common/resources.js'; +import { constObservable, IObservable, ISettableObservable, 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 { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; +import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ISessionChangeEvent, ISendRequestOptions, ISessionsBrowseAction, ISessionsProvider, ISessionType } from '../../sessions/browser/sessionsProvider.js'; +import { CopilotCLISessionType } from '../../sessions/browser/sessionTypes.js'; +import { ISession, IChat, IGitHubInfo, ISessionWorkspace, SessionStatus } from '../../sessions/common/sessionData.js'; + +interface IChatData { + /** Globally unique session ID (`providerId:localId`). */ + readonly id: 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; + /** GitHub information associated with this session, if any. */ + readonly gitHubInfo: IObservable; +} + +export interface IRemoteAgentHostSessionsProviderConfig { + readonly address: string; + readonly name: string; +} + +/** + * Adapts agent host session metadata into the {@link IChatData} facade. + */ +class RemoteSessionAdapter implements IChatData { + + readonly id: string; + readonly resource: URI; + readonly providerId: string; + readonly sessionType: string; + readonly icon = Codicon.remote; + readonly createdAt: Date; + readonly workspace: ISettableObservable; + readonly title: ISettableObservable; + readonly updatedAt: ISettableObservable; + readonly status = observableValue('status', SessionStatus.Completed); + readonly changes = observableValue('changes', []); + readonly modelId = observableValue('modelId', undefined); + readonly mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('mode', undefined); + readonly loading = observableValue('loading', false); + readonly isArchived = observableValue('isArchived', false); + readonly isRead = observableValue('isRead', true); + readonly description: ISettableObservable; + readonly lastTurnEnd: ISettableObservable; + readonly gitHubInfo = observableValue('gitHubInfo', undefined); + + /** The agent provider name (e.g. 'copilot') for constructing backend URIs. */ + readonly agentProvider: string; + + constructor( + metadata: IAgentSessionMetadata, + providerId: string, + resourceScheme: string, + logicalSessionType: string, + providerLabel: string, + connectionAuthority: string, + ) { + const rawId = AgentSession.id(metadata.session); + this.agentProvider = AgentSession.provider(metadata.session) ?? 'copilot'; + this.resource = URI.from({ scheme: resourceScheme, path: `/${rawId}` }); + this.id = `${providerId}:${this.resource.toString()}`; + this.providerId = providerId; + this.sessionType = logicalSessionType; + this.createdAt = new Date(metadata.startTime); + this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`); + this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); + this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); + this.description = observableValue('description', new MarkdownString().appendText(providerLabel)); + this.workspace = observableValue('workspace', metadata.workingDirectory + ? RemoteAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory, providerLabel, connectionAuthority) + : undefined); + } + + update(metadata: IAgentSessionMetadata): void { + this.title.set(metadata.summary ?? this.title.get(), undefined); + this.updatedAt.set(new Date(metadata.modifiedTime), undefined); + this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined); + } +} + +/** + * Sessions provider for a remote agent host connection. + * One instance is created per connection and handles all agents on it. + * + * Fully implements {@link ISessionsProvider}: + * - Session listing via {@link IAgentConnection.listSessions} with incremental updates + * - Session creation and initial request sending via {@link IChatService} + * - Session actions (delete, rename, etc.) where supported by the protocol + * + * **URI/ID scheme:** + * - **rawId** - unique session identifier (e.g. `abc123`), used as the cache key. + * - **resource** - `{sessionType}:///{rawId}` (e.g. `remote-host__4321-copilot:///abc123`). + * The scheme routes the chat service to the correct {@link AgentHostSessionHandler}. + * - **sessionId** - `{providerId}:{resource}` - the provider-scoped ID used by + * {@link ISessionsProvider} methods. The rawId can be extracted from the resource path. + * - Protocol operations (e.g. `disposeSession`) use the canonical agent session URI + * (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}. + */ +export class RemoteAgentHostSessionsProvider extends Disposable implements ISessionsProvider { + + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon = Codicon.remote; + readonly sessionTypes: readonly ISessionType[]; + readonly capabilities = { multipleChatsPerSession: false }; + readonly remoteAddress: string; + private _outputChannelId: string | undefined; + get outputChannelId(): string | undefined { return this._outputChannelId; } + + private readonly _connectionStatus = observableValue('connectionStatus', RemoteAgentHostConnectionStatus.Disconnected); + readonly connectionStatus: IObservable = this._connectionStatus; + + private readonly _onDidChangeSessions = this._register(new Emitter()); + readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; + + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; + + readonly browseActions: readonly ISessionsBrowseAction[]; + + /** Cache of adapted sessions, keyed by raw session ID. */ + private readonly _sessionCache = new Map(); + + /** + * Temporary session that has been sent (first turn dispatched) but not yet + * committed to a real backend session. Shown in the session list until the + * server creates the backend session, at which point it is replaced via + * {@link _onDidReplaceSession}. + */ + private _pendingSession: ISession | undefined; + + /** Selected model for the current new session. */ + private _selectedModelId: string | undefined; + /** Settable status for the current new session, kept to avoid unsafe cast from IObservable. */ + private _currentNewSessionStatus: ISettableObservable | undefined; + + private _connection: IAgentConnection | undefined; + private _defaultDirectory: string | undefined; + private readonly _connectionListeners = this._register(new DisposableStore()); + private readonly _onDidDisconnect = this._register(new Emitter()); + private readonly _connectionAuthority: string; + + constructor( + config: IRemoteAgentHostSessionsProviderConfig, + @IFileDialogService private readonly _fileDialogService: IFileDialogService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IChatService private readonly _chatService: IChatService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @INotificationService private readonly _notificationService: INotificationService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._connectionAuthority = agentHostAuthority(config.address); + const displayName = config.name || config.address; + + this.id = `agenthost-${this._connectionAuthority}`; + this.label = displayName; + this.remoteAddress = config.address; + + this.sessionTypes = [CopilotCLISessionType]; + + this.browseActions = [{ + label: localize('folders', "Folders"), + // label: localize('browseRemote', "Browse Folders ({0})...", displayName), + icon: Codicon.remote, + providerId: this.id, + execute: () => this._browseForFolder(), + }]; + } + + /** + * Update the connection status for this provider. + * Called by the contribution when connection state changes. + */ + setConnectionStatus(status: RemoteAgentHostConnectionStatus): void { + this._connectionStatus.set(status, undefined); + } + + /** + * Set the output channel ID for this provider's IPC log. + */ + setOutputChannelId(id: string): void { + this._outputChannelId = id; + } + + // -- Connection Management -- + + /** + * Wire a live connection to this provider, enabling session operations and folder browsing. + */ + setConnection(connection: IAgentConnection, defaultDirectory?: string): void { + if (this._connection === connection && this._defaultDirectory === defaultDirectory) { + return; + } + + this._connectionListeners.clear(); + this._connection = connection; + this._defaultDirectory = defaultDirectory; + + this._connectionListeners.add(connection.onDidNotification(n => { + if (n.type === 'notify/sessionAdded') { + this._handleSessionAdded(n.summary); + } else if (n.type === 'notify/sessionRemoved') { + this._handleSessionRemoved(n.session); + } + })); + + // Handle session state changes from the server + this._connectionListeners.add(this._connection.onDidAction(e => { + if (e.action.type === ActionType.SessionTurnComplete && isSessionAction(e.action)) { + const cts = new CancellationTokenSource(); + this._refreshSessions(cts.token).finally(() => cts.dispose()); + } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { + this._handleTitleChanged(e.action.session, e.action.title); + } + })); + + // Always refresh sessions when a connection is (re)established + const cts = new CancellationTokenSource(); + this._cacheInitialized = true; + this._refreshSessions(cts.token).finally(() => cts.dispose()); + } + + /** + * Clear the connection, e.g. when the remote host disconnects. + * Retains the provider registration so it remains visible in the UI. + */ + clearConnection(): void { + this._connectionListeners.clear(); + this._onDidDisconnect.fire(); + this._connection = undefined; + this._defaultDirectory = undefined; + + const removed: ISession[] = Array.from(this._sessionCache.values()).map(cached => this._chatToSession(cached)); + if (this._pendingSession) { + removed.push(this._pendingSession); + this._pendingSession = undefined; + } + this._sessionCache.clear(); + this._cacheInitialized = false; + if (removed.length > 0) { + this._onDidChangeSessions.fire({ added: [], removed, changed: [] }); + } + } + + // -- Workspaces -- + + /** + * Builds workspace metadata from a working directory path on the remote host. + */ + static buildWorkspace(workingDirectory: URI, providerLabel: string, _connectionAuthority: string): ISessionWorkspace { + const folderName = basename(workingDirectory) || workingDirectory.path; + return { + label: `${folderName} [${providerLabel}]`, + icon: Codicon.remote, + repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }; + } + + private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { + const folderName = basename(uri) || uri.path; + return { + label: `${folderName} [${this.label}]`, + icon: Codicon.remote, + repositories: [{ uri, workingDirectory: undefined, detail: this.label, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + } + + resolveWorkspace(repositoryUri: URI): ISessionWorkspace { + return this._buildWorkspaceFromUri(repositoryUri); + } + + // -- Sessions -- + + getSessionTypes(_sessionId: string): ISessionType[] { + return [...this.sessionTypes]; + } + + getSessions(): ISession[] { + this._ensureSessionCache(); + const sessions: ISession[] = Array.from(this._sessionCache.values()).map(cached => this._chatToSession(cached)); + if (this._pendingSession) { + sessions.push(this._pendingSession); + } + return sessions; + } + + // -- Session Lifecycle -- + + private _currentNewSession: IChatData | undefined; + + createNewSession(workspace: ISessionWorkspace): ISession { + if (!this._connection) { + throw new Error(localize('notConnectedSession', "Cannot create session: not connected to remote agent host '{0}'.", this.label)); + } + + const workspaceUri = workspace.repositories[0]?.uri; + if (!workspaceUri) { + throw new Error('Workspace has no repository URI'); + } + + // Reset draft state from any prior unsent session + this._currentNewSession = undefined; + this._selectedModelId = undefined; + + const resource = URI.from({ scheme: this._sessionTypeForProvider('copilot'), path: `/untitled-${generateUuid()}` }); + const status = observableValue(this, SessionStatus.Untitled); + const session: IChatData = { + id: `${this.id}:${resource.toString()}`, + resource, + providerId: this.id, + sessionType: this.sessionTypes[0].id, + icon: Codicon.remote, + createdAt: new Date(), + workspace: observableValue(this, workspace), + title: observableValue(this, ''), + updatedAt: observableValue(this, new Date()), + status, + 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), + gitHubInfo: observableValue(this, undefined), + }; + this._currentNewSession = session; + this._currentNewSessionStatus = status; + return this._chatToSession(session); + } + + setSessionType(_sessionId: string, _type: ISessionType): ISession { + throw new Error('Remote agent host sessions do not support changing session type'); + } + + setModel(sessionId: string, modelId: string): void { + if (this._currentNewSession?.id === sessionId) { + this._selectedModelId = modelId; + } + } + + // -- Session Actions -- + + async archiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(true, undefined); + this._storeArchivedState(rawId, true); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } + } + + async unarchiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(false, undefined); + this._storeArchivedState(rawId, false); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } + } + + async deleteSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId && this._connection) { + await this._connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); + this._sessionCache.delete(rawId); + this._storeArchivedState(rawId, false); + this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); + } + } + + async renameChat(sessionId: string, _chatUri: URI, _title: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId && this._connection) { + cached.title.set(_title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + const action = { type: ActionType.SessionTitleChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), title: _title }; + this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq()); + } + } + + async deleteChat(_sessionId: string, _chatUri: URI): Promise { + // Agent host sessions don't support deleting individual chats + } + + setRead(sessionId: string, read: boolean): void { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached) { + cached.isRead.set(read, undefined); + } + } + + async sendAndCreateChat(chatId: string, options: ISendRequestOptions): Promise { + if (!this._connection) { + throw new Error(localize('notConnectedSend', "Cannot send request: not connected to remote agent host '{0}'.", this.label)); + } + + const session = this._currentNewSession; + if (!session || session.id !== chatId) { + throw new Error(`Session '${chatId}' not found or not a new session`); + } + + const { query, attachedContext } = options; + + const contribution = this._chatSessionsService.getChatSessionContribution(this._sessionTypeForProvider('copilot')); + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: this._selectedModelId, + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: true, + modeInstructions: undefined, + modeId: 'agent', + applyCodeBlockSuggestionId: undefined, + permissionLevel: undefined, + }, + agentIdSilent: contribution?.type, + attachedContext, + }; + + // Open chat widget + await this._chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); + const chatWidget = await this._chatWidgetService.openSession(session.resource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[RemoteAgentHost] Failed to open chat widget'); + } + + // Load session model and apply selected model + const modelRef = await this._chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, CancellationToken.None); + if (modelRef) { + if (this._selectedModelId) { + const languageModel = this._languageModelsService.lookupLanguageModel(this._selectedModelId); + if (languageModel) { + modelRef.object.inputModel.setState({ selectedModel: { identifier: this._selectedModelId, metadata: languageModel } }); + } + } + modelRef.dispose(); + } + + // Capture existing session keys before sending so we can detect the new + // backend session. Must be captured before sendRequest because the + // backend session may be created during the send and arrive via + // notification before sendRequest resolves. + const existingKeys = new Set(this._sessionCache.keys()); + + // Send request through the chat service, which delegates to the + // AgentHostSessionHandler content provider for turn handling + const result = await this._chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[RemoteAgentHost] sendRequest rejected: ${result.reason}`); + } + + // Add the untitled session to the pending set so it stays visible in the + // session list while the turn is in progress. It will be replaced + // by the committed session once the backend session appears. + this._currentNewSessionStatus?.set(SessionStatus.InProgress, undefined); + const newSession = this._chatToSession(session); + this._pendingSession = newSession; + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); + + this._selectedModelId = undefined; + this._currentNewSessionStatus = undefined; + + // Wait for the real backend session to appear (via server notification + // after the handler creates it), then replace the temporary entry. + try { + const committedSession = await this._waitForNewSession(existingKeys); + if (committedSession) { + this._currentNewSession = undefined; + this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); + return committedSession; + } + } catch { + // Connection lost or timeout — clean up + } finally { + this._pendingSession = undefined; + } + + // Fallback: keep the temp session visible + this._currentNewSession = undefined; + return newSession; + } + + // -- Private: Session Cache -- + + private _cacheInitialized = false; + + private _ensureSessionCache(): void { + if (this._cacheInitialized) { + return; + } + this._cacheInitialized = true; + const cts = new CancellationTokenSource(); + this._refreshSessions(cts.token).finally(() => cts.dispose()); + } + + private async _refreshSessions(_token: unknown): Promise { + if (!this._connection) { + return; + } + try { + const sessions = await this._connection.listSessions(); + const currentKeys = new Set(); + const added: ISession[] = []; + const changed: ISession[] = []; + + for (const meta of sessions) { + const rawId = AgentSession.id(meta.session); + const provider = AgentSession.provider(meta.session) ?? 'copilot'; + currentKeys.add(rawId); + + const existing = this._sessionCache.get(rawId); + if (existing) { + existing.update(meta); + changed.push(this._chatToSession(existing)); + } else { + const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + this._restoreArchivedState(rawId, cached); + this._sessionCache.set(rawId, cached); + added.push(this._chatToSession(cached)); + } + } + + const removed: ISession[] = []; + for (const [key, cached] of this._sessionCache) { + if (!currentKeys.has(key)) { + this._sessionCache.delete(key); + removed.push(this._chatToSession(cached)); + } + } + + // Prune archived IDs that no longer exist on the server + this._pruneArchivedIds(currentKeys); + + if (added.length > 0 || removed.length > 0 || changed.length > 0) { + this._onDidChangeSessions.fire({ added, removed, changed }); + } + } catch { + // Connection may not be ready yet + } + } + + /** + * Wait for a new session to appear in the cache that wasn't present before. + * Tries an immediate refresh, then listens for the session-added notification. + * Returns `undefined` if the connection is lost or a timeout expires. + */ + private async _waitForNewSession(existingKeys: Set): Promise { + // First, try an immediate refresh + await this._refreshSessions(CancellationToken.None); + for (const [key, cached] of this._sessionCache) { + if (!existingKeys.has(key)) { + return this._chatToSession(cached); + } + } + + // If not found yet, wait for the next onDidChangeSessions event, + // bounded by a timeout and aborted on disconnect. + const waitDisposables = new DisposableStore(); + try { + const sessionPromise = new Promise((resolve) => { + waitDisposables.add(this._onDidChangeSessions.event(e => { + const newSession = e.added.find(s => { + const rawId = s.resource.path.substring(1); + return !existingKeys.has(rawId); + }); + if (newSession) { + resolve(newSession); + } + })); + waitDisposables.add(this._onDidDisconnect.event(() => resolve(undefined))); + }); + return await raceTimeout(sessionPromise, 30_000); + } finally { + waitDisposables.dispose(); + } + } + + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void { + const sessionUri = URI.parse(summary.resource); + const rawId = AgentSession.id(sessionUri); + if (this._sessionCache.has(rawId)) { + return; + } + + const provider = AgentSession.provider(sessionUri) ?? 'copilot'; + const workingDir = typeof summary.workingDirectory === 'string' + ? toAgentHostUri(URI.parse(summary.workingDirectory), this._connectionAuthority) + : undefined; + const meta: IAgentSessionMetadata = { + session: sessionUri, + startTime: summary.createdAt, + modifiedTime: summary.modifiedAt, + summary: summary.title, + workingDirectory: workingDir, + }; + const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + this._restoreArchivedState(rawId, cached); + this._sessionCache.set(rawId, cached); + this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] }); + } + + private _handleSessionRemoved(session: URI | string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + this._sessionCache.delete(rawId); + this._storeArchivedState(rawId, false); + this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); + } + } + + private _handleTitleChanged(session: string, title: string): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.title.set(title, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + } + } + + // -- Private: Archived State Persistence -- + + private get _archivedStorageKey(): string { + return `remoteAgentHost.archivedSessions.${this.id}`; + } + + private _loadArchivedIds(): Set { + const raw = this._storageService.get(this._archivedStorageKey, StorageScope.PROFILE); + if (!raw) { + return new Set(); + } + try { + const parsed = JSON.parse(raw); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } + } + + private _storeArchivedState(rawId: string, archived: boolean): void { + const ids = this._loadArchivedIds(); + if (archived) { + ids.add(rawId); + } else { + ids.delete(rawId); + } + this._storageService.store(this._archivedStorageKey, JSON.stringify([...ids]), StorageScope.PROFILE, StorageTarget.USER); + } + + private _restoreArchivedState(rawId: string, session: RemoteSessionAdapter): void { + if (this._loadArchivedIds().has(rawId)) { + session.isArchived.set(true, undefined); + } + } + + /** + * Remove archived IDs that are no longer present on the server. + * Called after a full refresh to prevent unbounded growth of stored IDs. + */ + private _pruneArchivedIds(validIds: Set): void { + const archivedIds = this._loadArchivedIds(); + let changed = false; + for (const id of archivedIds) { + if (!validIds.has(id)) { + archivedIds.delete(id); + changed = true; + } + } + if (changed) { + if (archivedIds.size === 0) { + this._storageService.remove(this._archivedStorageKey, StorageScope.PROFILE); + } else { + this._storageService.store(this._archivedStorageKey, JSON.stringify([...archivedIds]), StorageScope.PROFILE, StorageTarget.USER); + } + } + } + + private _rawIdFromChatId(chatId: string): string | undefined { + const prefix = `${this.id}:`; + const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; + try { + return URI.parse(resourceStr).path.substring(1) || undefined; + } catch { + return undefined; + } + } + + private _sessionTypeForProvider(provider: string): string { + return `remote-${this._connectionAuthority}-${provider}`; + } + + // -- Private: Browse -- + + private async _browseForFolder(): Promise { + if (!this._connection) { + this._notificationService.error(localize('notConnected', "Unable to connect to remote agent host '{0}'.", this.label)); + return undefined; + } + + const defaultUri = agentHostUri(this._connectionAuthority, this._defaultDirectory ?? '/'); + + 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]) { + return this._buildWorkspaceFromUri(selected[0]); + } + } catch { + // dialog was cancelled or failed + } + return undefined; + } + + private _chatToSession(chat: IChatData): ISession { + const mainChat: IChat = { + resource: chat.resource, + createdAt: chat.createdAt, + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + isArchived: chat.isArchived, + isRead: chat.isRead, + description: chat.description, + lastTurnEnd: chat.lastTurnEnd, + }; + const session: ISession = { + sessionId: chat.id, + resource: chat.resource, + providerId: chat.providerId, + sessionType: chat.sessionType, + icon: chat.icon, + createdAt: chat.createdAt, + workspace: chat.workspace, + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + loading: chat.loading, + isArchived: chat.isArchived, + isRead: chat.isRead, + description: chat.description, + lastTurnEnd: chat.lastTurnEnd, + gitHubInfo: chat.gitHubInfo, + chats: constObservable([mainChat]), + mainChat, + }; + return session; + } +} diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/syncedCustomizationBundler.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/syncedCustomizationBundler.ts new file mode 100644 index 0000000000000..dc7f5a85e5b54 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/syncedCustomizationBundler.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. + *--------------------------------------------------------------------------------------------*/ + +// Re-export from the workbench layer so both local and remote contributions +// can share the same bundler implementation. +export { SyncedCustomizationBundler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.js'; +export { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts new file mode 100644 index 0000000000000..5bcceacf8f5e4 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -0,0 +1,607 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import type { ISessionAction } from '../../../../../platform/agentHost/common/state/protocol/action-origin.generated.js'; +import { NotificationType } from '../../../../../platform/agentHost/common/state/protocol/notifications.js'; +import { ActionType, type IActionEnvelope, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionStatus as ProtocolSessionStatus } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService, type ChatSendResult } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ISessionChangeEvent } from '../../../sessions/browser/sessionsProvider.js'; +import { CopilotCLISessionType } from '../../../sessions/browser/sessionTypes.js'; +import { SessionStatus } from '../../../sessions/common/sessionData.js'; +import { RemoteAgentHostSessionsProvider, type IRemoteAgentHostSessionsProviderConfig } from '../../browser/remoteAgentHostSessionsProvider.js'; + +// ---- Mock connection -------------------------------------------------------- + +class MockAgentConnection extends mock() { + declare readonly _serviceBrand: undefined; + + private readonly _onDidAction = new Emitter(); + override readonly onDidAction = this._onDidAction.event; + private readonly _onDidNotification = new Emitter(); + override readonly onDidNotification = this._onDidNotification.event; + + override readonly clientId = 'test-client-1'; + private readonly _sessions = new Map(); + public disposedSessions: URI[] = []; + public dispatchedActions: { action: ISessionAction; clientId: string; clientSeq: number }[] = []; + + private _nextSeq = 0; + + override nextClientSeq(): number { + return this._nextSeq++; + } + + override async listSessions(): Promise { + return [...this._sessions.values()]; + } + + override async disposeSession(session: URI): Promise { + this.disposedSessions.push(session); + const rawId = AgentSession.id(session); + this._sessions.delete(rawId); + } + + override dispatchAction(action: ISessionAction, clientId: string, clientSeq: number): void { + this.dispatchedActions.push({ action, clientId, clientSeq }); + } + + // Test helpers + addSession(meta: IAgentSessionMetadata): void { + this._sessions.set(AgentSession.id(meta.session), meta); + } + + fireNotification(n: INotification): void { + this._onDidNotification.fire(n); + } + + fireAction(envelope: IActionEnvelope): void { + this._onDidAction.fire(envelope); + } + + dispose(): void { + this._onDidAction.dispose(); + this._onDidNotification.dispose(); + } +} + +// ---- Test helpers ----------------------------------------------------------- + +function createSession(id: string, opts?: { provider?: string; summary?: string; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { + return { + session: AgentSession.uri(opts?.provider ?? 'copilot', id), + startTime: opts?.startTime ?? 1000, + modifiedTime: opts?.modifiedTime ?? 2000, + summary: opts?.summary, + workingDirectory: opts?.workingDirectory, + }; +} + +function createProvider(disposables: DisposableStore, connection: MockAgentConnection, overrides?: { address?: string; connectionName?: string | undefined }): RemoteAgentHostSessionsProvider { + const instantiationService = disposables.add(new TestInstantiationService()); + + instantiationService.stub(IFileDialogService, {}); + instantiationService.stub(INotificationService, { error: () => { } }); + instantiationService.stub(IChatSessionsService, { + getChatSessionContribution: () => ({ type: 'remote-test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), + getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), + }); + instantiationService.stub(IChatService, { + acquireOrLoadSession: async () => undefined, + sendRequest: async (): Promise => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }), + }); + instantiationService.stub(IChatWidgetService, { + openSession: async () => undefined, + }); + instantiationService.stub(ILanguageModelsService, { + lookupLanguageModel: () => undefined, + }); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + + const config: IRemoteAgentHostSessionsProviderConfig = { + address: overrides?.address ?? 'localhost:4321', + name: overrides !== undefined && Object.prototype.hasOwnProperty.call(overrides, 'connectionName') ? overrides.connectionName ?? '' : 'Test Host', + }; + + const provider = disposables.add(instantiationService.createInstance(RemoteAgentHostSessionsProvider, config)); + provider.setConnection(connection); + return provider; +} + +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; workingDirectory?: string }): void { + const provider = opts?.provider ?? 'copilot'; + const sessionUri = AgentSession.uri(provider, rawId); + connection.fireNotification({ + type: NotificationType.SessionAdded, + summary: { + resource: sessionUri.toString(), + provider, + title: opts?.title ?? `Session ${rawId}`, + status: ProtocolSessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + workingDirectory: opts?.workingDirectory, + }, + }); +} + +function fireSessionRemoved(connection: MockAgentConnection, rawId: string, provider = 'copilot'): void { + const sessionUri = AgentSession.uri(provider, rawId); + connection.fireNotification({ + type: NotificationType.SessionRemoved, + session: sessionUri.toString(), + }); +} + +suite('RemoteAgentHostSessionsProvider', () => { + const disposables = new DisposableStore(); + let connection: MockAgentConnection; + + setup(() => { + connection = new MockAgentConnection(); + disposables.add(toDisposable(() => connection.dispose())); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // ---- Provider identity ------- + + test('derives id, label, and sessionType from config', () => { + const provider = createProvider(disposables, connection, { address: '10.0.0.1:8080', connectionName: 'My Host' }); + + assert.ok(provider.id.startsWith('agenthost-')); + assert.ok(provider.id.includes('10.0.0.1')); + assert.strictEqual(provider.label, 'My Host'); + assert.strictEqual(provider.sessionTypes.length, 1); + assert.strictEqual(provider.sessionTypes[0].id, CopilotCLISessionType.id); + }); + + test('falls back to address-based label when no name given', () => { + const provider = createProvider(disposables, connection, { connectionName: undefined, address: 'myhost:9999' }); + + assert.strictEqual(provider.label, 'myhost:9999'); + }); + + // ---- Workspace resolution ------- + + test('resolveWorkspace builds workspace from URI', () => { + const provider = createProvider(disposables, connection); + const uri = URI.parse('vscode-agent-host://auth/home/user/project'); + const ws = provider.resolveWorkspace(uri); + + assert.strictEqual(ws.label, 'project [Test Host]'); + assert.strictEqual(ws.repositories.length, 1); + assert.strictEqual(ws.repositories[0].uri.toString(), uri.toString()); + }); + + // ---- Browse actions ------- + + test('has one browse action for remote folders', () => { + const provider = createProvider(disposables, connection); + + assert.strictEqual(provider.browseActions.length, 1); + assert.ok(provider.browseActions[0].label.includes('Folders')); + assert.strictEqual(provider.browseActions[0].providerId, provider.id); + }); + + // ---- Session listing via notifications ------- + + test('onDidChangeSessions fires when session added notification arrives', () => { + const provider = createProvider(disposables, connection); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionAdded(connection, 'notif-1', { title: 'Notif Session' }); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].added.length, 1); + assert.strictEqual(changes[0].added[0].title.get(), 'Notif Session'); + }); + + test('accepts session notifications from any agent provider', () => { + const provider = createProvider(disposables, connection); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionAdded(connection, 'other-sess', { provider: 'other-agent', title: 'Other Session' }); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].added.length, 1); + }); + + test('session removed notification removes from cache', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'to-remove', { title: 'Removed' }); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionRemoved(connection, 'to-remove'); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].removed.length, 1); + }); + + test('duplicate session added notification is ignored', () => { + const provider = createProvider(disposables, connection); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); + fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); + + assert.strictEqual(changes.length, 1); + }); + + test('removing non-existent session is no-op', () => { + const provider = createProvider(disposables, connection); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionRemoved(connection, 'does-not-exist'); + + assert.strictEqual(changes.length, 0); + }); + + test('session removed notification removes session from any provider', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'cross-prov', { provider: 'other-agent', title: 'Cross Provider' }); + assert.strictEqual(provider.getSessions().length, 1); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + fireSessionRemoved(connection, 'cross-prov', 'other-agent'); + + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].removed.length, 1); + assert.strictEqual(provider.getSessions().length, 0); + }); + + // ---- Session listing via refresh ------- + + test('getSessions populates from connection.listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('list-1', { summary: 'First' })); + connection.addSession(createSession('list-2', { summary: 'Second' })); + + const provider = createProvider(disposables, connection); + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + provider.getSessions(); + await timeout(0); + + assert.ok(changes.length > 0); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 2); + })); + + // ---- Session lifecycle ------- + + test('createNewSession returns session with correct fields', () => { + const provider = createProvider(disposables, connection); + const workspace = { + label: 'my-project', + icon: { id: 'remote' }, + repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }; + + const session = provider.createNewSession(workspace); + + assert.strictEqual(session.providerId, provider.id); + assert.strictEqual(session.status.get(), SessionStatus.Untitled); + assert.ok(session.workspace.get()); + assert.strictEqual(session.workspace.get()?.label, 'my-project'); + // sessionType should be the logical type, not the resource scheme + assert.strictEqual(session.sessionType, provider.sessionTypes[0].id); + }); + + test('createNewSession throws when no repository URI', () => { + const provider = createProvider(disposables, connection); + const workspace = { label: 'empty', icon: { id: 'remote' }, repositories: [], requiresWorkspaceTrust: false }; + + assert.throws(() => provider.createNewSession(workspace), /Workspace has no repository URI/); + }); + + test('setSessionType throws', () => { + const provider = createProvider(disposables, connection); + assert.throws(() => provider.setSessionType('x', { id: 'y', label: 'Y', icon: { id: 'x' } })); + }); + + // ---- Session actions ------- + + test('deleteSession calls disposeSession with backend agent URI and removes from cache', async () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'del-sess', { title: 'To Delete' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'To Delete'); + assert.ok(target, 'Session should exist'); + + await provider.deleteSession(target!.sessionId); + + assert.strictEqual(connection.disposedSessions.length, 1); + // The disposed URI must be a backend agent session URI (copilot://del-sess), + // not the UI resource (remote-localhost_4321-copilot:///del-sess) + const disposedUri = connection.disposedSessions[0]; + assert.strictEqual(AgentSession.provider(disposedUri), 'copilot'); + assert.strictEqual(AgentSession.id(disposedUri), 'del-sess'); + // Session should no longer appear in getSessions + const remaining = provider.getSessions(); + assert.strictEqual(remaining.find((s) => s.title.get() === 'To Delete'), undefined); + }); + + test('setRead toggles read state locally', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'read-sess', { title: 'Read Test' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Read Test'); + assert.ok(target, 'Session should exist'); + + assert.strictEqual(target!.isRead.get(), true); + provider.setRead(target!.sessionId, false); + assert.strictEqual(target!.isRead.get(), false); + }); + + // ---- Rename ------- + + test('renameSession dispatches SessionTitleChanged action with correct session URI', async () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'rename-sess', { title: 'Old Title' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Old Title'); + assert.ok(target, 'Session should exist'); + + await provider.renameChat(target!.sessionId, target!.resource, 'New Title'); + + assert.strictEqual(connection.dispatchedActions.length, 1); + const dispatched = connection.dispatchedActions[0]; + assert.strictEqual(dispatched.action.type, ActionType.SessionTitleChanged); + assert.strictEqual((dispatched.action as { title: string }).title, 'New Title'); + // The session URI in the action must be the backend agent session URI + const actionSession = (dispatched.action as { session: string }).session; + assert.strictEqual(AgentSession.provider(actionSession), 'copilot'); + assert.strictEqual(AgentSession.id(actionSession), 'rename-sess'); + assert.strictEqual(dispatched.clientId, 'test-client-1'); + }); + + test('renameSession updates local title optimistically', async () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'rename-opt', { title: 'Before' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Before'); + assert.ok(target); + + await provider.renameChat(target!.sessionId, target!.resource, 'After'); + + assert.strictEqual(target!.title.get(), 'After'); + }); + + test('renameSession is no-op for unknown chatId', async () => { + const provider = createProvider(disposables, connection); + await provider.renameChat('nonexistent-id', URI.parse('test://nonexistent'), 'Ignored'); + + assert.strictEqual(connection.dispatchedActions.length, 0); + }); + + test('renameSession increments clientSeq on successive calls', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('seq-sess', { summary: 'Seq Test' })); + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Seq Test'); + assert.ok(target); + + await provider.renameChat(target!.sessionId, target!.resource, 'Title 1'); + await provider.renameChat(target!.sessionId, target!.resource, 'Title 2'); + + assert.strictEqual(connection.dispatchedActions.length, 2); + assert.strictEqual(connection.dispatchedActions[0].clientSeq, 0); + assert.strictEqual(connection.dispatchedActions[1].clientSeq, 1); + })); + + test('server-echoed SessionTitleChanged updates cached title', () => { + const provider = createProvider(disposables, connection); + fireSessionAdded(connection, 'echo-sess', { title: 'Original' }); + + const sessions = provider.getSessions(); + const target = sessions.find((s) => s.title.get() === 'Original'); + assert.ok(target); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + // Simulate the server echoing a title change (from auto-generation or another client) + connection.fireAction({ + action: { + type: ActionType.SessionTitleChanged, + session: AgentSession.uri('copilot', 'echo-sess').toString(), + title: 'Server Title', + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + assert.strictEqual(target!.title.get(), 'Server Title'); + assert.strictEqual(changes.length, 1); + assert.strictEqual(changes[0].changed.length, 1); + }); + + test('renamed title survives session refresh from listSessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Simulate server persisting the renamed title: after rename, listSessions + // returns the updated summary + connection.addSession(createSession('persist-sess', { summary: 'Original Title' })); + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + // Verify initial title + let sessions = provider.getSessions(); + let target = sessions.find((s) => s.title.get() === 'Original Title'); + assert.ok(target, 'Session should exist with original title'); + + // Simulate server updating the summary (as would happen after persist + reload) + connection.addSession(createSession('persist-sess', { summary: 'Renamed Title', modifiedTime: 5000 })); + + // Trigger refresh via turnComplete action (simulates what happens on reload) + connection.fireAction({ + action: { + type: 'session/turnComplete', + session: AgentSession.uri('copilot', 'persist-sess').toString(), + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + await timeout(0); + + sessions = provider.getSessions(); + target = sessions.find((s) => s.title.get() === 'Renamed Title'); + assert.ok(target, 'Session should have renamed title after refresh'); + })); + + // ---- Send ------- + + test('sendAndCreateChat throws for unknown session', async () => { + const provider = createProvider(disposables, connection); + await assert.rejects( + () => provider.sendAndCreateChat('nonexistent', { query: 'test' }), + /not found or not a new session/, + ); + }); + + // ---- Session data adapter ------- + + test('session adapter has correct workspace from working directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('ws-sess', { summary: 'WS Test', workingDirectory: URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/myrepo') })); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const wsSession = sessions.find((s) => s.title.get() === 'WS Test'); + assert.ok(wsSession, 'Session with working directory should exist'); + + const workspace = wsSession!.workspace.get(); + assert.ok(workspace, 'Workspace should be populated'); + assert.strictEqual(workspace!.label, 'myrepo [Test Host]'); + })); + + test('session adapter without working directory has no workspace', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('no-ws-sess', { summary: 'No WS' })); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const session = sessions.find((s) => s.title.get() === 'No WS'); + assert.ok(session, 'Session should exist'); + assert.strictEqual(session!.workspace.get(), undefined); + })); + + test('session adapter uses raw ID as fallback title', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('abcdef1234567890')); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const session = sessions[0]; + assert.ok(session); + assert.strictEqual(session.title.get(), 'Session abcdef12'); + })); + + // ---- Refresh on turnComplete ------- + + test('turnComplete action triggers session refresh for matching provider', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('turn-sess', { summary: 'Before', modifiedTime: 1000 })); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + // Update on connection side + connection.addSession(createSession('turn-sess', { summary: 'After', modifiedTime: 5000 })); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); + + connection.fireAction({ + action: { + type: 'session/turnComplete', + session: AgentSession.uri('copilot', 'turn-sess').toString(), + }, + serverSeq: 1, + origin: undefined, + } as IActionEnvelope); + + await timeout(0); + + assert.ok(changes.length > 0); + const updatedSession = provider.getSessions().find((s) => s.title.get() === 'After'); + assert.ok(updatedSession, 'Session should have updated title'); + })); + + // ---- getSessionTypes ------- + + test('getSessionTypes returns available types', () => { + const provider = createProvider(disposables, connection); + const workspace = { + label: 'project', + icon: { id: 'remote' }, + repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }; + const session = provider.createNewSession(workspace); + const types = provider.getSessionTypes(session.sessionId); + + assert.strictEqual(types.length, 1); + }); + + // ---- sessionType on adapters ------- + + test('session adapter uses logical session type, not resource scheme', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + connection.addSession(createSession('type-sess', { summary: 'Type Test' })); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const sessions = provider.getSessions(); + const session = sessions.find((s) => s.title.get() === 'Type Test'); + assert.ok(session, 'Session should exist'); + // sessionType should be the logical type (agent-host-copilot), not the resource scheme + assert.strictEqual(session!.sessionType, provider.sessionTypes[0].id); + })); +}); 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..d6a5afc39a044 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * 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 onDidChangeLayout?: () => 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')); + + const toolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + // Re-layout when toolbar items change (e.g., Plugins item appearing after extension activation) + this._register(toolbar.onDidChangeMenuItems(() => { + options?.onDidChangeLayout?.(); + })); + + 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?.onDidChangeLayout?.(); + }); + }; + + 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..00d1029684dc3 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.type === PromptsType.skill) { + continue; + } + items.push({ storage: c.storage, uri: c.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/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd57c..8ff9655fcdcb6 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 10px; + padding: 6px 0 10px 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..dfadd6868cfba --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -0,0 +1,365 @@ +/*--------------------------------------------------------------------------------------------- + * 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 10px; + } + + .pane-body & .monaco-tree-sticky-container { + padding: 0 10px; + } + + .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; + } + } + + /* Pinned but not hovered/focused: show only the unpin action */ + .monaco-list-row:not(:hover):not(.focused):has(.session-item.pinned) { + .session-title-toolbar { + display: block; + + .action-item:not(:last-child) { + display: none; + } + } + + .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-pulse > .codicon { + animation: session-needs-input-pulse 2s ease-in-out infinite; + } + + @media (prefers-reduced-motion: reduce) { + &.session-icon-pulse > .codicon { + animation: none; + } + } + } + + .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: 2px; + + .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 p { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .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: 0 1 auto; + } + + .session-section-count { + opacity: 0.7; + margin-left: auto; + margin-right: 4px; + } + + .session-section-toolbar { + margin-left: auto; + display: none; + } +} + +.monaco-list-row:hover .session-section .session-section-toolbar, +.monaco-list-row.focused .session-section .session-section-toolbar { + display: block; +} + +.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..c9d3684cfb2a9 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -4,26 +4,61 @@ *--------------------------------------------------------------------------------------------*/ /* Container - button style hover */ -.command-center .agent-sessions-titlebar-container { +.agent-sessions-workbench .command-center .monaco-action-bar .actions-container { display: flex; - width: 38vw; - max-width: 600px; + align-items: center; +} + +.agent-sessions-workbench .command-center .monaco-action-bar .actions-container > .action-item.agent-sessions-titlebar-container + .action-item { + position: relative; + margin-left: 8px; + padding-left: 12px; +} + +.agent-sessions-workbench .command-center .monaco-action-bar .actions-container > .action-item.agent-sessions-titlebar-container + .action-item::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 1px; + height: 16px; + transform: translateY(-50%); + background-color: var(--vscode-commandCenter-border); + pointer-events: none; +} + +.command-center .agent-sessions-titlebar-container { 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; +} + +.agent-sessions-workbench:not(.nosidebar) .command-center .agent-sessions-titlebar-container { + padding-left: 0; } -.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 +67,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; @@ -49,11 +83,16 @@ font-size: 14px; } +.command-center > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item:not(.disabled) > .action-label { + color: var(--vscode-icon-foreground); +} + /* Label (title) */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: 500; } /* Repository/folder label */ @@ -70,34 +109,34 @@ flex-shrink: 0; } -/* Changes container */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { - display: flex; - align-items: center; - gap: 3px; - cursor: pointer; - padding: 0 4px; - border-radius: 3px; -} - -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes:hover { - background-color: var(--vscode-toolbar-hoverBackground); -} - -/* Changes icon */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-icon { +/* Provider label (shown for untitled sessions) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-provider { display: flex; align-items: center; flex-shrink: 0; - font-size: 14px; + opacity: 0.7; } -/* Insertions */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-added { - color: var(--vscode-gitDecoration-addedResourceForeground); +/* Sidebar toggle unread badge */ +.agent-sessions-workbench .action-item.sidebar-toggle-action { + position: relative; } -/* Deletions */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-removed { - color: var(--vscode-gitDecoration-deletedResourceForeground); +.agent-sessions-workbench .action-item.sidebar-toggle-action .sidebar-toggle-badge { + position: absolute; + top: 0; + right: -4px; + min-width: 14px; + height: 14px; + line-height: 14px; + padding: 0 3px; + box-sizing: border-box; + font-size: 9px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: center; + border-radius: 4px; + background-color: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); + pointer-events: none; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be701275e..671f45d5d058b 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: 6px; } .agent-sessions-header { @@ -73,30 +74,115 @@ } .agent-sessions-new-button-container { - padding: 6px 12px 8px 12px; + padding: 0 10px 10px 10px; } .agent-sessions-new-button-container .monaco-button { - position: relative; display: flex; align-items: center; - justify-content: center; + justify-content: space-between; + column-gap: 12px; + padding-right: 5px; + text-align: left; + } + + .agent-sessions-new-button-container .monaco-button:focus { + outline-offset: -1px !important; + } + + .agent-sessions-new-button-container .monaco-button .new-session-button-label { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + } + + .agent-sessions-new-button-container .monaco-button .new-session-button-label > .codicon { + margin: 0; + font-size: 14px; + line-height: 14px; + transform: translateY(1px); } .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint { - position: absolute; - right: 10px; + display: inline-block; + flex-shrink: 0; + font-family: var(--monaco-monospace-font); font-size: 11px; - opacity: 0.5; + line-height: 1; + padding: 2px 4px; + border: 1px solid transparent; + border-radius: 2px; + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + box-shadow: none; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint .monaco-keybinding { + line-height: 1; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key { + margin: 0 1px; + padding: 0; + min-width: auto; + border: 0; + background-color: transparent; + box-shadow: none; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:first-child { + margin-left: 0; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint .monaco-keybinding > .monaco-keybinding-key:last-child { + margin-right: 0; } .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. */ - /* Override section header padding to align with dot indicators */ - .agent-session-section { - padding-left: 12px; +.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; } + + /* 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/sessionTypes.ts b/src/vs/sessions/contrib/sessions/browser/sessionTypes.ts new file mode 100644 index 0000000000000..1f182f790949a --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionTypes.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. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { ISessionType } from './sessionsProvider.js'; + +/** Session type ID for local Copilot CLI sessions. */ +export const COPILOT_CLI_SESSION_TYPE = 'copilotcli'; + +/** Session type ID for Copilot Cloud sessions. */ +export const COPILOT_CLOUD_SESSION_TYPE = 'copilot-cloud-agent'; + +/** Copilot CLI session type — local background agent running in a Git worktree. */ +export const CopilotCLISessionType: ISessionType = { + id: COPILOT_CLI_SESSION_TYPE, + label: localize('copilotCLI', "Copilot CLI"), + icon: Codicon.copilot, +}; + +/** Copilot Cloud session type - cloud-hosted agent. */ +export const CopilotCloudSessionType: ISessionType = { + id: COPILOT_CLOUD_SESSION_TYPE, + label: localize('copilotCloud', "Cloud"), + icon: Codicon.cloud, +}; diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index b81e8fa62848f..c1ba5566cc900 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -12,10 +12,11 @@ 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'; +import './sessionsActions.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 +33,20 @@ const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); - -registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(SessionsAuxiliaryBarContribution.ID, SessionsAuxiliaryBarContribution, WorkbenchPhase.AfterRestored); +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); + +registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts new file mode 100644 index 0000000000000..eaccd6ee13a9d --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.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 { fromNow } from '../../../../base/common/date.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { ISessionsManagementService } from './sessionsManagementService.js'; +import { ISession } from '../common/sessionData.js'; + +// -- Show Sessions Picker -- + +export const SHOW_SESSIONS_PICKER_COMMAND_ID = 'sessions.showSessionsPicker'; + +registerAction2(class ShowSessionsPickerAction extends Action2 { + constructor() { + super({ + id: SHOW_SESSIONS_PICKER_COMMAND_ID, + title: localize2('showSessionsPicker', "Show Sessions Picker"), + f1: true, + category: SessionsCategories.Sessions, + }); + } + + override async run(accessor: ServicesAccessor) { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const quickInputService = accessor.get(IQuickInputService); + + const sessions = sessionsManagementService.getSessions() + .filter(s => !s.isArchived.get()) + .sort((a, b) => b.updatedAt.get().getTime() - a.updatedAt.get().getTime()); + + const activeSessionId = sessionsManagementService.activeSession.get()?.sessionId; + + interface ISessionPickItem extends IQuickPickItem { + session?: ISession; + } + + const items: (ISessionPickItem | IQuickPickSeparator)[] = []; + + // New session item + items.push({ + label: `$(add) ${localize('newSession', "New Session")}`, + session: undefined, + }); + + if (sessions.length > 0) { + items.push({ type: 'separator', label: localize('recentSessions', "Recent Sessions") }); + + for (const session of sessions) { + const title = session.title.get() || localize('untitledSession', "New Session"); + const workspace = session.workspace.get(); + const parts: string[] = []; + if (workspace) { + parts.push(workspace.label); + } + parts.push(fromNow(session.updatedAt.get(), true, true)); + + items.push({ + label: title, + description: parts.join(' \u00B7 '), + iconClass: ThemeIcon.asClassName(session.icon), + session, + picked: activeSessionId !== undefined && session.sessionId === activeSessionId, + }); + } + } + + const picker = quickInputService.createQuickPick({ useSeparators: true }); + picker.items = items; + picker.placeholder = localize('searchSessions', "Search sessions by name"); + picker.canAcceptInBackground = true; + + const disposables = new DisposableStore(); + disposables.add(picker); + + disposables.add(picker.onDidAccept(() => { + const [selected] = picker.selectedItems; + if (selected) { + if (selected.session) { + sessionsManagementService.openSession(selected.session.resource); + } else { + sessionsManagementService.openNewSessionView(); + } + } + picker.hide(); + })); + disposables.add(picker.onDidHide(() => disposables.dispose())); + + picker.show(); + } +}); 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/sessionsGroupModel.ts b/src/vs/sessions/contrib/sessions/browser/sessionsGroupModel.ts new file mode 100644 index 0000000000000..e1fd9001c575b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsGroupModel.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const SESSIONS_GROUPS_STORAGE_KEY = 'sessions.groups'; + +interface ISerializedSessionGroup { + readonly sessionId: string; + readonly chatIds: string[]; + readonly activeChatIndex: number; +} + +export interface ISessionsGroupModelChange { + readonly sessionId: string; +} + +export interface ISessionsGroupModelChatAddedChange { + readonly sessionId: string; + readonly chatId: string; +} + +interface Group { + readonly chatIds: string[]; + activeChatIndex: number; +} + +/** + * Model that tracks which chats belong to which session group. + * Persisted via IStorageService so data survives window reload. + * + * Every group always has at least one chat and an active chat. + * Removing the last chat from a group deletes the group. + */ +export class SessionsGroupModel extends Disposable { + + private readonly _groups = new Map(); + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _onDidAddChatToSession = this._register(new Emitter()); + readonly onDidAddChatToSession: Event = this._onDidAddChatToSession.event; + + constructor( + private readonly _storageService: IStorageService, + ) { + super(); + this._load(); + } + + /** + * Returns all session IDs that have groups. + */ + getSessionIds(): string[] { + return [...this._groups.keys()]; + } + + /** + * Returns the chat IDs belonging to a session group, or an empty array + * if the session does not exist. + */ + getChatIds(sessionId: string): readonly string[] { + return this._groups.get(sessionId)?.chatIds ?? []; + } + + /** + * Returns the session ID that contains the given chat, or `undefined` + * if the chat is not in any group. + */ + getSessionIdForChat(chatId: string): string | undefined { + for (const [sessionId, group] of this._groups) { + if (group.chatIds.includes(chatId)) { + return sessionId; + } + } + return undefined; + } + + /** + * Returns the active chat ID for a session group. + * @throws if the session does not exist. + */ + getActiveChatId(sessionId: string): string { + const group = this._groups.get(sessionId); + if (!group) { + throw new Error(`Session group '${sessionId}' does not exist`); + } + return group.chatIds[group.activeChatIndex]; + } + + /** + * Returns whether a session group exists for the given session ID. + */ + hasGroupForSession(sessionId: string): boolean { + return this._groups.has(sessionId); + } + + /** + * Sets the active chat for its session group. The chat must belong to + * a group. If it does not, this is a no-op. + */ + setActiveChatId(chatId: string): void { + const sessionId = this.getSessionIdForChat(chatId); + if (!sessionId) { + return; + } + const group = this._groups.get(sessionId)!; + const idx = group.chatIds.indexOf(chatId); + if (group.activeChatIndex === idx) { + return; + } + group.activeChatIndex = idx; + this._save(); + this._onDidChange.fire({ sessionId }); + } + + /** + * Adds a chat to a session group. Creates the session group if it does + * not exist yet. The first chat added becomes the active chat. + * Adding the same chat twice is a no-op. + */ + addChat(sessionId: string, chatId: string): void { + let group = this._groups.get(sessionId); + if (!group) { + group = { chatIds: [], activeChatIndex: 0 }; + this._groups.set(sessionId, group); + } + if (group.chatIds.includes(chatId)) { + return; + } + group.chatIds.push(chatId); + this._save(); + this._onDidChange.fire({ sessionId }); + this._onDidAddChatToSession.fire({ sessionId, chatId }); + } + + /** + * Removes a chat from its session group. If the chat is not in + * any group this is a no-op. If it was the last chat in the group, + * the group is deleted. + */ + removeChat(chatId: string): void { + for (const [sessionId, group] of this._groups) { + const idx = group.chatIds.indexOf(chatId); + if (idx !== -1) { + group.chatIds.splice(idx, 1); + if (group.chatIds.length === 0) { + this._groups.delete(sessionId); + } else if (group.activeChatIndex >= group.chatIds.length) { + group.activeChatIndex = group.chatIds.length - 1; + } else if (idx < group.activeChatIndex) { + group.activeChatIndex--; + } + this._save(); + this._onDidChange.fire({ sessionId }); + return; + } + } + } + + /** + * Deletes an entire session group and all its chat associations. + */ + deleteSession(sessionId: string): void { + if (!this._groups.delete(sessionId)) { + return; + } + this._save(); + this._onDidChange.fire({ sessionId }); + } + + // #region Persistence + + private _load(): void { + const raw = this._storageService.get(SESSIONS_GROUPS_STORAGE_KEY, StorageScope.PROFILE); + if (!raw) { + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + + if (!Array.isArray(parsed)) { + return; + } + + for (const entry of parsed) { + if ( + typeof entry === 'object' && entry !== null && + typeof (entry as ISerializedSessionGroup).sessionId === 'string' && + Array.isArray((entry as ISerializedSessionGroup).chatIds) + ) { + const chatIds = (entry as ISerializedSessionGroup).chatIds.filter( + (id: unknown): id is string => typeof id === 'string', + ); + if (chatIds.length === 0) { + continue; + } + const sid = (entry as ISerializedSessionGroup).sessionId; + const activeChatIndex = (entry as Record).activeChatIndex; + this._groups.set(sid, { + chatIds, + activeChatIndex: typeof activeChatIndex === 'number' && activeChatIndex >= 0 && activeChatIndex < chatIds.length + ? activeChatIndex + : 0, + }); + } + } + } + + private _save(): void { + const data: ISerializedSessionGroup[] = []; + for (const [sessionId, group] of this._groups) { + data.push({ sessionId, chatIds: group.chatIds, activeChatIndex: group.activeChatIndex }); + } + this._storageService.store( + SESSIONS_GROUPS_STORAGE_KEY, + JSON.stringify(data), + StorageScope.PROFILE, + StorageTarget.MACHINE, + ); + } + + // #endregion +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 059c8e51cdd7f..59bef7382b210 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -3,69 +3,116 @@ * 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 { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IObservable, ISettableObservable, autorun, 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 { COPILOT_CLI_SESSION_TYPE } from './sessionTypes.js'; +import { ISessionsProvidersService } from './sessionsProvidersService.js'; +import { ISessionType, ISendRequestOptions, ISessionChangeEvent } from './sessionsProvider.js'; +import { ISession, IChat, ISessionWorkspace, SessionStatus } from '../common/sessionData.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 { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionBackgroundProviderContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; -export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); +export const ActiveSessionSupportsMultiChatContext = new RawContextKey('activeSessionSupportsMultiChat', false, localize('activeSessionSupportsMultiChat', "Whether the active session's provider supports multiple chats per session")); //#region Active Session Service const LAST_SELECTED_SESSION_KEY = 'agentSessions.lastSelectedSession'; -const repositoryOptionId = 'repository'; +const ACTIVE_PROVIDER_KEY = 'sessions.activeProviderId'; + +/** + * Event fired when sessions change within a provider. + */ +export interface ISessionsChangeEvent { + readonly added: readonly ISession[]; + readonly removed: readonly ISession[]; + readonly changed: readonly ISession[]; +} + +/** + * An active session extends {@link ISession} with the currently focused chat. + */ +export interface IActiveSession extends ISession { + /** The currently active chat within this session. */ + readonly activeChat: IObservable; +} /** * 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(): ISession[]; /** - * The worktree URI for this session. + * Get a session by its resource URI. */ - readonly worktree: URI | undefined; -}; + getSession(resource: URI): ISession | undefined; -export interface ISessionsManagementService { - readonly _serviceBrand: undefined; + /** + * Get all session types from all registered providers. + */ + getSessionTypes(session: ISession): ISessionType[]; /** - * Observable for the currently active session. + * Get all session types from all registered providers. */ - readonly activeSession: IObservable; + getAllSessionTypes(): ISessionType[]; /** - * Returns the currently active session, if any. + * Fires when available session types change (providers added/removed). */ - getActiveSession(): IActiveSessionItem | undefined; + readonly onDidChangeSessionTypes: Event; + + /** + * Fires when sessions change across any provider. + */ + readonly onDidChangeSessions: Event; + + // -- Active Session -- + + /** + * Observable for the currently active session as {@link IActiveSession}. + */ + readonly activeSession: IObservable; + + /** + * Observable for the currently active sessions provider ID. + * When only one provider exists, it is selected automatically. + */ + 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. + * Sets `isNewChatSession` context to false and opens the active chat belonging to the session. + */ + openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): Promise; + + /** + * Open a specific chat within a session. + * Sets `isNewChatSession` context to false and opens the chat. */ - openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise; + openChat(session: ISession, chatUri: URI): Promise; /** * Switch to the new-session view. @@ -74,22 +121,35 @@ 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. + * Create a new session for the given workspace. + * Delegates to the provider identified by providerId. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + createNewSession(providerId: string, workspace: ISessionWorkspace): ISession; /** - * Open a new session, apply options, and send the initial request. - * Looks up the session by resource URI and builds send options from it. + * Send a request, creating a new chat in the session. */ - sendRequestForNewSession(sessionResource: URI): Promise; + sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise; /** - * Commit files in a worktree and refresh the agent sessions model - * so the Changes view reflects the update. + * Update the session type for a new session. */ - commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise; + setSessionType(session: ISession, type: ISessionType): Promise; + + // -- Session Actions -- + + /** Archive a session. */ + archiveSession(session: ISession): Promise; + /** Unarchive a session. */ + unarchiveSession(session: ISession): Promise; + /** Delete a session. */ + deleteSession(session: ISession): Promise; + /** Delete a single chat from a session by its URI. */ + deleteChat(session: ISession, chatUri: URI): Promise; + /** Rename a chat within a session. */ + renameChat(session: ISession, chatUri: URI, title: string): Promise; + /** Mark a session as read or unread. */ + setRead(session: ISession, read: boolean): void; } export const ISessionsManagementService = createDecorator('sessionsManagementService'); @@ -98,31 +158,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 _onDidChangeSessionTypes = this._register(new Emitter()); + readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; - private readonly _newSession = this._register(new MutableDisposable()); + private _sessionTypes: readonly ISessionType[] = []; + + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; + 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; + private readonly _supportsMultiChat: IContextKey; + private _activeChatObservable: ISettableObservable | undefined; + private _activeSessionDisposables = this._register(new DisposableStore()); 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); + this._supportsMultiChat = ActiveSessionSupportsMultiChatContext.bindTo(contextKeyService); // Load last selected session this.lastSelectedSession = this.loadLastSelectedSession(); @@ -130,239 +203,210 @@ 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(); - } - } + // When a provider replaces a temp session with a committed one, update the active session + this._register(this.sessionsProvidersService.onDidReplaceSession(e => this.onDidReplaceSession(e.from, e.to))); + + // 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 onDidReplaceSession(from: ISession, to: ISession): void { + if (this._activeSession.get()?.sessionId === from.sessionId) { + this.setActiveSession(to); + this._onDidChangeSessions.fire({ + added: [], + removed: [from], + changed: [to], + }); } } - private getRepositoryFromMetadata(metadata: { readonly [key: string]: unknown } | undefined): [URI | undefined, URI | undefined] { - if (!metadata) { - return [undefined, undefined]; + private onDidChangeSessionsFromSessionsProviders(e: ISessionChangeEvent): void { + this._onDidChangeSessions.fire(e); + const currentActive = this._activeSession.get(); + + if (!currentActive) { + return; } - const repositoryPath = metadata?.repositoryPath as string | undefined; - const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; + if (e.removed.length) { + if (e.removed.some(r => r.sessionId === currentActive.sessionId)) { + this.openNewSessionView(); + return; + } + } + } - const worktreePath = metadata?.worktreePath as string | undefined; - const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + getSessions(): ISession[] { + return this.sessionsProvidersService.getSessions(); + } - return [ - URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, - URI.isUri(worktreePathUri) ? worktreePathUri : undefined]; + getSession(resource: URI): ISession | undefined { + return this.sessionsProvidersService.getSessions().find(s => + this.uriIdentityService.extUri.isEqual(s.resource, resource) + ); } - private getRepositoryFromSessionOption(sessionResource: URI): URI | undefined { - const optionValue = this.chatSessionsService.getSessionOption(sessionResource, repositoryOptionId); - if (!optionValue) { - return undefined; + getSessionTypes(session: ISession): ISessionType[] { + const provider = this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId); + if (!provider) { + return []; } + return provider.getSessionTypes(session.sessionId); + } - // Option value can be a string or IChatSessionProviderOptionItem - const optionId = typeof optionValue === 'string' ? optionValue : (optionValue as IChatSessionProviderOptionItem).id; - if (!optionId) { - return undefined; - } + getAllSessionTypes(): ISessionType[] { + return [...this._sessionTypes]; + } - try { - return URI.parse(optionId); - } catch { - return undefined; + 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 { + async openChat(session: ISession, chatUri: URI): Promise { + this.logService.info(`[SessionsManagement] openChat: ${chatUri.toString()} provider=${session.providerId}`); 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); + this.setActiveSession(session); + + // Update active chat + if (this._activeChatObservable) { + const activeSession = this._activeSession.get(); + if (activeSession) { + const chat = activeSession.chats.get().find(c => this.uriIdentityService.extUri.isEqual(c.resource, chatUri)); + if (chat) { + this._activeChatObservable.set(chat, undefined); + } + } + } + + await this.chatWidgetService.openSession(chatUri, ChatViewPaneTarget); + } + + async openSession(sessionResource: URI, options?: { preserveFocus?: boolean }): 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); + this.setRead(sessionData, true); // mark as read when opened + + await this.chatWidgetService.openSession(sessionData.resource, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + createNewSession(providerId: string, workspace: ISessionWorkspace): ISession { 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 { + const session = provider.createNewSession(workspace); this.setActiveSession(session); - await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); + return session; } - /** - * 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; + async setSessionType(session: ISession, 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`); } - const repository = this.getRepositoryFromSessionOption(sessionResource); - this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); - } - 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; - } + const updatedSession = provider.setSessionType(session.sessionId, type); - if (!this.uriIdentityService.extUri.isEqual(sessionResource, session.resource)) { - this.logService.error(`[SessionsManagementService] Session resource mismatch. Expected: ${session.resource.toString()}, received: ${sessionResource.toString()}`); - return; + const activeSession = this._activeSession.get(); + if (activeSession && activeSession.sessionId === updatedSession.sessionId) { + this.setActiveSession(updatedSession); } - - const query = session.query; - if (!query) { - this.logService.error('[SessionsManagementService] No query set on session'); - return; - } - - 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, - }); + async sendAndCreateChat(session: ISession, options: ISendRequestOptions): Promise { + this.isNewChatSessionContext.set(false); + + const setActiveChatToLast = () => { + const activeSession = this._activeSession.get(); + if (this._activeChatObservable && activeSession) { + const chats = activeSession.chats.get(); + const lastChat = chats[chats.length - 1]; + if (lastChat) { + this._activeChatObservable.set(lastChat, undefined); } - modelRef.dispose(); } - } - - // 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; - } - - // 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(); - } + // Listen for chats changing during the send (subsequent chat appears in the group) + const chatsListener = autorun(reader => { + session.chats.read(reader); + setActiveChatToLast(); + }); - if (newSession) { - this.setActiveSession(newSession); + try { + const updatedSession = await this.sessionsProvidersService.sendAndCreateChat(session.sessionId, options); + this.setActiveSession(updatedSession); + setActiveChatToLast(); + } finally { + chatsListener.dispose(); } } @@ -371,57 +415,57 @@ 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 { + private setActiveSession(session: ISession | undefined): void { + if (this._activeSession.get()?.sessionId === session?.sessionId) { + return; + } + + // Update context keys from session data + this._activeSessionProviderId.set(session?.providerId ?? ''); + this._activeSessionType.set(session?.sessionType ?? ''); + this._isBackgroundProvider.set(session?.sessionType === COPILOT_CLI_SESSION_TYPE); + const provider = session ? this.sessionsProvidersService.getProviders().find(p => p.id === session.providerId) : undefined; + this._supportsMultiChat.set(provider?.capabilities.multipleChatsPerSession ?? false); + + if (session && session.status.get() !== SessionStatus.Untitled) { + this.lastSelectedSession = session.resource; + } + + if (session) { + this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`); + } else { + this.logService.trace('[ActiveSessionService] Active session cleared'); + } + this._activeSessionDisposables.clear(); - let activeSessionItem: IActiveSessionItem | undefined; + 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); + // Create the active chat observable, defaulting to the first chat + const activeChatObs = observableValue(`activeChat-${session.sessionId}`, session.chats.get()[0]); + this._activeChatObservable = activeChatObs; + const activeSession: IActiveSession = { + ...session, + activeChat: activeChatObs, + }; + + this._activeSession.set(activeSession, undefined); + + // Listen for the active session becoming archived + if (!session.isArchived.get()) { + this._activeSessionDisposables.add(autorun(reader => { + if (session.isArchived.read(reader)) { + this.openNewSessionView(); } })); } - this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}, repository: ${activeSessionItem.repository?.toString() ?? 'none'}`); } else { - this.logService.info('[ActiveSessionService] Active session cleared'); + this._activeChatObservable = undefined; + this._activeSession.set(undefined, undefined); } - this._activeSession.set(activeSessionItem, undefined); - } - - async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise { - const worktreeUri = session.worktree; - if (!worktreeUri) { - throw new Error('Cannot commit worktree files: active session has no associated worktree'); - } - for (const fileUri of fileUris) { - await this.commandService.executeCommand( - 'github.copilot.cli.sessions.commitToWorktree', - { worktreeUri, fileUri } - ); - } - await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); } private loadLastSelectedSession(): URI | undefined { @@ -442,6 +486,32 @@ 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: ISession): Promise { + await this.sessionsProvidersService.archiveSession(session.sessionId); + } + + async unarchiveSession(session: ISession): Promise { + await this.sessionsProvidersService.unarchiveSession(session.sessionId); + } + + async deleteSession(session: ISession): Promise { + await this.sessionsProvidersService.deleteSession(session.sessionId); + } + + async deleteChat(session: ISession, chatUri: URI): Promise { + await this.sessionsProvidersService.deleteChat(session.sessionId, chatUri); + } + + async renameChat(session: ISession, chatUri: URI, title: string): Promise { + await this.sessionsProvidersService.renameChat(session.sessionId, chatUri, title); + } + + setRead(session: ISession, 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..2d6c93cadeb38 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvider.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 { Event } from '../../../../base/common/event.js'; +import { IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ISession, 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; +} + +/** + * 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 ISessionChangeEvent { + readonly added: readonly ISession[]; + readonly removed: readonly ISession[]; + readonly changed: readonly ISession[]; +} + +/** + * 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[]; +} + +/** + * Capabilities declared by a sessions provider. + * Consumers check these before surfacing provider-specific features in the UI. + */ +export interface ISessionsProviderCapabilities { + /** Whether the provider supports multiple chats within a single session. */ + readonly multipleChatsPerSession: boolean; +} + +/** + * 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[]; + /** Capabilities supported by this provider. */ + readonly capabilities: ISessionsProviderCapabilities; + + // -- Remote Connection (optional, used by remote agent host providers) -- + + /** Connection status observable, present on remote providers. */ + readonly connectionStatus?: IObservable; + /** Remote address string, present on remote providers. */ + readonly remoteAddress?: string; + /** Output channel ID for remote provider logs. */ + outputChannelId?: string; + + // -- 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(): ISession[]; + /** Fires when sessions are added, removed, or changed. */ + readonly onDidChangeSessions: Event; + /** + * Optional. Fires when a temporary (untitled) session is atomically replaced + * by a committed session after the first turn. + * + * @internal This is an implementation detail of the Copilot Chat sessions + * provider. Do not implement or consume this event in other providers. + */ + readonly onDidReplaceSession?: Event<{ readonly from: ISession; readonly to: ISession }>; + + // -- Session Management -- + + /** Create a new session for the given workspace. */ + createNewSession(workspace: ISessionWorkspace): ISession; + /** Update the session type for a session. */ + setSessionType(sessionId: string, type: ISessionType): ISession; + /** Returns session types available for the given session. */ + getSessionTypes(sessionId: string): ISessionType[]; + /** Rename a chat within a session. */ + renameChat(sessionId: string, chatUri: URI, 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; + /** Delete a single chat from a session. */ + deleteChat(sessionId: string, chatUri: URI): Promise; + /** Mark a session as read or unread. */ + setRead(sessionId: string, read: boolean): void; + + // -- Send -- + /** Send a request, creating a new chat in the session. */ + sendAndCreateChat(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..f53545762dcfb --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsProvidersService.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 { 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 { ISession, ISessionWorkspace } from '../common/sessionData.js'; +import { ISessionChangeEvent, ISessionsProvider, ISessionType, ISendRequestOptions } 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[]; + getProvider(providerId: string): T | undefined; + /** 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(sessionId: string): ISessionType[]; + + // -- Aggregated Sessions -- + + /** Get all sessions from all providers. */ + getSessions(): ISession[]; + /** Get a session by chat resource. */ + getSession(chatId: string): ISession | undefined; + /** Fires when sessions change across any provider. */ + readonly onDidChangeSessions: Event; + /** + * Fires when a temporary (untitled) session is atomically replaced by a + * committed session. Forwarded from providers that implement + * {@link ISessionsProvider.onDidReplaceSession}. + * + * @internal This is an implementation detail of the Copilot Chat sessions + * provider. Do not consume this event outside of {@link ISessionsManagementService}. + */ + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }>; + + // -- 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; + /** Delete a single chat from a session. */ + deleteChat(sessionId: string, chatUri: URI): Promise; + /** Rename a chat within a session. */ + renameChat(sessionId: string, chatUri: URI, 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; + /** Send a request, creating a new chat in the session. Routed to the correct provider. */ + sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise; +} + +/** + * 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; + + private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.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); + })); + + // Forward replace session events if the provider supports them + if (provider.onDidReplaceSession) { + disposables.add(provider.onDidReplaceSession(e => { + this._onDidReplaceSession.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); + } + + getProvider(providerId: string): T | undefined { + return this._providers.get(providerId)?.provider as T | undefined; + } + + // -- Session Types -- + + getSessionTypesForProvider(providerId: string): ISessionType[] { + const entry = this._providers.get(providerId); + if (!entry) { + return []; + } + return [...entry.provider.sessionTypes]; + } + + getSessionTypes(sessionId: string): ISessionType[] { + const { provider } = this._resolveProvider(sessionId); + if (!provider) { + return []; + } + return provider.getSessionTypes(sessionId); + } + + // -- Aggregated Sessions -- + + getSessions(): ISession[] { + const sessions: ISession[] = []; + for (const { provider } of this._providers.values()) { + sessions.push(...provider.getSessions()); + } + return sessions; + } + + getSession(sessionId: string): ISession | 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 renameChat(sessionId: string, chatUri: URI, title: string): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.renameChat(sessionId, chatUri, title); + } + } + + async deleteChat(sessionId: string, chatUri: URI): Promise { + const { provider } = this._resolveProvider(sessionId); + if (provider) { + await provider.deleteChat(sessionId, chatUri); + } + } + + 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); + } + + async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise { + const { provider } = this._resolveProvider(sessionId); + if (!provider) { + throw new Error(`Sessions provider for session ID '${sessionId}' not found`); + } + return provider.sendAndCreateChat(sessionId, options); + } + + // -- Private Helpers -- + + /** + * Extract provider ID from a globally unique session ID and look up the provider. + */ + private _resolveProvider(chatId: string): { provider: ISessionsProvider | undefined; localId: string } { + const separatorIndex = chatId.indexOf(SESSION_ID_SEPARATOR); + if (separatorIndex === -1) { + return { provider: undefined, localId: chatId }; + } + const providerId = chatId.substring(0, separatorIndex); + const localId = chatId.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/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index e21c25d56e9e7..a06c1f590722c 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,33 +4,36 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; - -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, EventType, getActiveWindow, reset } from '../../../../base/browser/dom.js'; +import { IAction, Separator } from '../../../../base/common/actions.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.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 { ActionViewItem, 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, MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.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'; -import { URI } from '../../../../base/common/uri.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; -import { FocusAgentSessionsAction } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsActions.js'; -import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.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 { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ISessionsProvidersService } from './sessionsProvidersService.js'; +import { SessionStatus } from '../common/sessionData.js'; +import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; +import { IsSessionArchivedContext, IsSessionPinnedContext, IsSessionReadContext, SessionItemContextMenuId } from './views/sessionsList.js'; +import { SessionsView, SessionsViewId } from './views/sessionsView.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { basename } from '../../../../base/common/resources.js'; /** * Sessions Title Bar Widget - renders the active chat session title @@ -39,8 +42,10 @@ import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextke * Shows the current chat session label as a clickable pill with: * - Kind icon at the beginning (provider type icon) * - Session title - * - Repository folder name - * - Changes summary (+insertions -deletions) + * - Repository folder name and active branch/worktree name when available + * + * Session actions (changes, terminal, etc.) are rendered via the + * SessionTitleActions menu toolbar next to the session title. * * On click, opens the sessions picker. */ @@ -48,7 +53,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { private _container: HTMLElement | undefined; private readonly _dynamicDisposables = this._register(new DisposableStore()); - private readonly _modelChangeListener = this._register(new MutableDisposable()); /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; @@ -59,25 +63,38 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { constructor( action: SubmenuItemAction, options: IBaseActionViewItemOptions | undefined, - @IInstantiationService private readonly instantiationService: IInstantiationService, @IHoverService private readonly hoverService: IHoverService, - @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, - @IChatService private readonly chatService: IChatService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly commandService: ICommandService, + @IViewsService private readonly viewsService: IViewsService, ) { 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.sessionsManagementService.activeSession.read(reader); + if (sessionData) { + sessionData.title.read(reader); + sessionData.status.read(reader); + sessionData.workspace.read(reader); + } + this.sessionsManagementService.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.sessionsManagementService.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 +134,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); - const changes = this._getChanges(); - + const repoDetailLabel = this._getRepositoryDetailLabel(); + const pillLabel = repoLabel ? `${label} \u00B7 ${repoLabel}${repoDetailLabel ? ` (${repoDetailLabel})` : ''}` : label; // 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 ?? ''}|${repoDetailLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -137,7 +154,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 +171,43 @@ 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); + // Folder shown next to the title + if (repoLabel) { + const separator1 = $('span.agent-sessions-titlebar-separator'); + separator1.textContent = '\u00B7'; + centerGroup.appendChild(separator1); - const removedEl = $('span.agent-sessions-titlebar-removed'); - removedEl.textContent = `-${changes.deletions}`; - changesEl.appendChild(removedEl); - - centerGroup.appendChild(changesEl); - - // 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 = repoDetailLabel ? `${repoLabel} (${repoDetailLabel})` : 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); + + // Hover + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + sessionPill, + pillLabel + )); // Keyboard handler this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { @@ -233,72 +222,25 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } } - /** - * Track title changes on the chat model for the given session resource. - * When the model title changes, re-render the widget. - */ - private _trackModelTitleChanges(sessionResource: URI | undefined): void { - this._modelChangeListener.clear(); - - if (!sessionResource) { - return; - } - - const model = this.chatService.getSession(sessionResource); - if (!model) { - return; - } - - this._modelChangeListener.value = model.onDidChange(e => { - if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { - this._lastRenderState = undefined; - this._render(); - } - }); - } - /** * 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; - } - } - - if (activeSession?.label) { - return activeSession.label; + const sessionData = this.sessionsManagementService.activeSession.get(); + if (sessionData) { + return sessionData.title.get() || localize('agentSessions.newSession', "New Session"); } - 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; - } - - // 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); + const sessionData = this.sessionsManagementService.activeSession.get(); + if (sessionData) { + return sessionData.icon; } - return undefined; } @@ -306,58 +248,170 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { * Get the repository label for the active session. */ private _getRepositoryLabel(): string | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { + const sessionData = this.sessionsManagementService.activeSession.get(); + if (sessionData) { + const workspace = sessionData.workspace.get(); + if (workspace) { + return workspace.label; + } + } + return undefined; + } + + private _getRepositoryDetailLabel(): string | undefined { + const sessionData = this.sessionsManagementService.activeSession.get(); + const workspace = sessionData?.workspace.get(); + const repository = workspace?.repositories[0]; + if (!workspace || !repository) { return undefined; } - const uri = activeSession.repository; - if (!uri) { + if (repository.detail && !workspace.label.includes(`[${repository.detail}]`)) { + return repository.detail; + } + + if (!repository.workingDirectory) { return undefined; } - return basename(uri); - } + const worktreeName = basename(repository.workingDirectory); + if (!worktreeName) { + return undefined; + } - /** - * Get the changes summary (insertions/deletions) for the active session. - */ - private _getChanges(): { insertions: number; deletions: number } | undefined { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { + const repositoryName = basename(repository.uri); + if (worktreeName === workspace.label || worktreeName === repositoryName) { return undefined; } - let changes: IAgentSession['changes'] | undefined; + return worktreeName; + } - if (isAgentSession(activeSession)) { - changes = activeSession.changes; - } else { - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - changes = agentSession?.changes; + private _showContextMenu(e: MouseEvent): void { + const sessionData = this.sessionsManagementService.activeSession.get(); + if (!sessionData) { + return; } - if (!changes || !hasValidDiff(changes)) { - return undefined; + if (this.contextKeyService.getContextKeyValue(IsNewChatSessionContext.key)) { + return; } - return getAgentChangesSummary(changes) ?? undefined; + const isPinned = this.viewsService.getViewWithId(SessionsViewId)?.sessionsControl?.isSessionPinned(sessionData) ?? false; + const contextOverlay: [string, boolean | string][] = [ + [IsSessionPinnedContext.key, isPinned], + [IsSessionArchivedContext.key, sessionData.isArchived.get()], + [IsSessionReadContext.key, sessionData.isRead.get()], + ['chatSessionType', sessionData.sessionType], + [ChatSessionProviderIdContext.key, sessionData.providerId], + ]; + + const menu = this.menuService.createMenu(SessionItemContextMenuId, this.contextKeyService.createOverlay(contextOverlay)); + + this.contextMenuService.showContextMenu({ + getActions: () => Separator.join(...menu.getActions({ arg: sessionData, shouldForwardArgs: true }).map(([, actions]) => actions)), + getAnchor: () => new StandardMouseEvent(getActiveWindow(), e), + }); + + menu.dispose(); } private _showSessionsPicker(): void { - const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { - overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) - }); - picker.pickAgentSession(); + this.commandService.executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); + } +} + +/** + * Custom action view item for the sidebar toggle button. + * Renders the tasklist icon with an unread session count badge. + */ +class SidebarToggleActionViewItem extends ActionViewItem { + + private _countBadge: HTMLElement | undefined; + + constructor( + context: unknown, + action: IAction, + options: IBaseActionViewItemOptions | undefined, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + ) { + super(context, action, { ...options, icon: true, label: false }); } - private _openChanges(): void { - const activeSession = this.activeSessionService.getActiveSession(); - if (!activeSession) { + override render(container: HTMLElement): void { + super.render(container); + + container.classList.add('sidebar-toggle-action'); + + // Add badge element for unread session count + this._countBadge = append(container, $('span.sidebar-toggle-badge')); + this._countBadge.setAttribute('aria-hidden', 'true'); + this._updateBadge(); + + // Single autorun that tracks all badge-relevant state: + // - session list changes (add/remove) via observableSignalFromEvent + // - individual session observable state (status, isRead, isArchived) + // - sidebar visibility changes + const sessionsChanged = observableSignalFromEvent(this, this.sessionsManagementService.onDidChangeSessions); + const sidebarVisibilityChanged = observableSignalFromEvent(this, handler => this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART) { + handler(e); + } + })); + this._register(autorun(reader => { + sessionsChanged.read(reader); + sidebarVisibilityChanged.read(reader); + for (const session of this.sessionsManagementService.getSessions()) { + session.isArchived.read(reader); + session.status.read(reader); + session.isRead.read(reader); + } + this.updateClass(); + this._updateBadge(); + })); + } + + protected override getClass(): string | undefined { + return this.layoutService.isVisible(Parts.SIDEBAR_PART) + ? ThemeIcon.asClassName(Codicon.layoutSidebarLeft) + : ThemeIcon.asClassName(Codicon.layoutSidebarLeftOff); + } + + private _updateBadge(): void { + if (!this._countBadge) { return; } - this.commandService.executeCommand(ViewAllSessionChangesAction.ID, activeSession.resource); + const unreadCount = this._countUnreadSessions(); + const sidebarVisible = this.layoutService.isVisible(Parts.SIDEBAR_PART); + + if (unreadCount > 0 && !sidebarVisible) { + this._countBadge.textContent = `${unreadCount}`; + this._countBadge.style.display = ''; + } else { + this._countBadge.style.display = 'none'; + } + + // Update accessible label to include unread count for screen readers + if (this.label) { + const baseLabel = this.action.label || localize('toggleSidebarA11y', "Toggle Primary Side Bar"); + if (unreadCount > 0 && !sidebarVisible) { + this.label.setAttribute('aria-label', localize('toggleSidebarUnread', "{0}, {1} unread session(s)", baseLabel, unreadCount)); + } else { + this.label.setAttribute('aria-label', baseLabel); + } + } + } + + private _countUnreadSessions(): number { + let unread = 0; + for (const session of this.sessionsManagementService.getSessions()) { + if (!session.isArchived.get() && session.status.get() === SessionStatus.Completed && !session.isRead.get()) { + unread++; + } + } + return unread; } } @@ -378,16 +432,16 @@ 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, + id: SHOW_SESSIONS_PICKER_COMMAND_ID, title: localize('showSessions', "Show Sessions"), }, group: 'a_sessions', @@ -395,11 +449,16 @@ 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; } return instantiationService.createInstance(SessionsTitleBarWidget, action, options); }, undefined)); + + // Register custom view item for sidebar toggle with unread badge + this._register(actionViewItemService.register(Menus.TitleBarLeftLayout, 'workbench.action.agentToggleSidebarVisibility', (action, options) => { + return instantiationService.createInstance(SidebarToggleActionViewItem, undefined, action, options); + }, 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..0fd0aabb14f93 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -0,0 +1,1343 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; +import { createMatches, FuzzyScore, IMatch } from '../../../../../base/common/filters.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { IReader, autorun } from '../../../../../base/common/observable.js'; +import { ThemeIcon, themeColorFromId } 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 { ChatSessionProviderIdContext } from '../../../../common/contextkeys.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 { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus } from '../../common/sessionData.js'; +import { ISessionsManagementService } from '../sessionsManagementService.js'; +import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } 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'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { CopilotCLISessionType } from '../sessionTypes.js'; + +const $ = DOM.$; + +export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); +export const SessionItemContextMenuId = new MenuId('SessionItemContextMenu'); +export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); +export const IsSessionPinnedContext = new RawContextKey('sessionItem.isPinned', false); +export const IsSessionArchivedContext = new RawContextKey('sessionItem.isArchived', false); +export const IsSessionReadContext = new RawContextKey('sessionItem.isRead', true); +export const SessionSectionTypeContext = new RawContextKey('sessionSection.type', ''); + +//#region Types + +export enum SessionsGrouping { + Workspace = 'workspace', + Date = 'date', +} + +export enum SessionsSorting { + Created = 'created', + Updated = 'updated', +} + +export interface ISessionSection { + readonly id: string; + readonly label: string; + readonly sessions: ISession[]; +} + +export interface ISessionShowMore { + readonly showMore: true; + readonly sectionLabel: string; + readonly remainingCount: number; +} + +export type SessionListItem = ISession | 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 = getFirstApprovalAcrossChats(this._approvalModel, element as ISession, undefined); + 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: HighlightedLabel; + 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; sorting: () => SessionsSorting; isPinned: (session: ISession) => boolean }, + private readonly approvalModel: AgentSessionApprovalModel | undefined, + private readonly instantiationService: IInstantiationService, + private readonly contextKeyService: IContextKeyService, + private readonly markdownRendererService: IMarkdownRendererService, + private readonly hoverService: IHoverService, + ) { } + + 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 = disposables.add(new HighlightedLabel(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, createMatches(node.filterData)); + } + + private renderSession(element: ISession, template: ISessionItemTemplate, matches?: IMatch[]): void { + template.elementDisposables.clear(); + + // Toolbar context + template.titleToolbar.context = element; + + // Context keys + const isPinned = this.options.isPinned(element); + IsSessionPinnedContext.bindTo(template.contextKeyService).set(isPinned); + IsSessionArchivedContext.bindTo(template.contextKeyService).set(element.isArchived.get()); + IsSessionReadContext.bindTo(template.contextKeyService).set(element.isRead.get()); + + // Pinned & archived styling — reactive + template.elementDisposables.add(autorun(reader => { + const isArchived = element.isArchived.read(reader); + template.container.classList.toggle('archived', isArchived); + // Only apply pinned styling when not archived to avoid persistent toolbars on archived sessions + template.container.classList.toggle('pinned', isPinned && !isArchived); + })); + + + // 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 gitHubInfo = element.gitHubInfo.read(reader); + DOM.clearNode(template.iconContainer); + const icon = this.getStatusIcon(sessionStatus, isRead, isArchived, gitHubInfo?.pullRequest?.icon); + const iconSpan = DOM.append(template.iconContainer, $(`span${ThemeIcon.asCSSSelector(icon)}`)); + iconSpan.style.color = icon.color ? asCssVariable(icon.color.id) : ''; + template.iconContainer.classList.toggle('session-icon-pulse', sessionStatus === SessionStatus.NeedsInput); + })); + + // Title — reactive + template.elementDisposables.add(autorun(reader => { + const titleText = element.title.read(reader); + template.title.set(titleText, matches); + })); + + // Details row — reactive: badge · diff stats · time + const timeDisposable = template.elementDisposables.add(new MutableDisposable()); + const descriptionDisposable = template.elementDisposables.add(new MutableDisposable()); + template.elementDisposables.add(autorun(reader => { + const sessionStatus = element.status.read(reader); + const changes = element.changes.read(reader); + const workspace = element.workspace.read(reader); + const description = element.description.read(reader); + let timeDate: Date | undefined; + + // When the session is InProgress or NeedsInput, hide workspace/diff/time details in this row + const hideDetails = sessionStatus === SessionStatus.InProgress || sessionStatus === SessionStatus.NeedsInput; + + if (!hideDetails) { + timeDate = this.options.sorting() === SessionsSorting.Updated ? element.updatedAt.read(reader) : element.createdAt; + } + // Clear and rebuild details row + DOM.clearNode(template.detailsRow); + const parts: HTMLElement[] = []; + + const isWorkspaceSession = workspace && + workspace.repositories.length > 0 && + workspace?.repositories[0].workingDirectory === undefined; + + // Session type icon in details row + // Disabling background icon - hacky but couldn't figure out how to do it from the new provider + if (element.sessionType !== CopilotCLISessionType.id) { + const typeIconEl = DOM.append(template.detailsRow, $('span.session-details-icon')); + DOM.append(typeIconEl, $(`span${ThemeIcon.asCSSSelector(element.icon)}`)); + parts.push(typeIconEl); + } else if ( + element.sessionType === CopilotCLISessionType.id && + sessionStatus !== SessionStatus.InProgress && + isWorkspaceSession + ) { + const typeIconEl = DOM.append(template.detailsRow, $('span.session-details-icon')); + DOM.append(typeIconEl, $(`span${ThemeIcon.asCSSSelector(Codicon.folder)}`)); + parts.push(typeIconEl); + } + + // Workspace badge — show when not grouped by workspace, + // or when the session is pinned/archived (their section headers + // don't carry the workspace name) + if (!hideDetails && workspace && ( + this.options.grouping() !== SessionsGrouping.Workspace || + this.options.isPinned(element) || + element.isArchived.read(reader) + )) { + 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 (!hideDetails && changes.length > 0) { + 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.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')); + if (description) { + descriptionDisposable.value = this.markdownRendererService.render(description, { sanitizerConfig: { replaceWithPlaintext: true } }, statusEl); + } else { + descriptionDisposable.clear(); + statusEl.textContent = 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')); + if (description) { + descriptionDisposable.value = this.markdownRendererService.render(description, { sanitizerConfig: { replaceWithPlaintext: true } }, statusEl); + } else { + descriptionDisposable.clear(); + statusEl.textContent = 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')); + if (description) { + descriptionDisposable.value = this.markdownRendererService.render(description, { sanitizerConfig: { replaceWithPlaintext: true } }, statusEl); + } else { + descriptionDisposable.clear(); + statusEl.textContent = localize('failed', "Failed"); + } + parts.push(statusEl); + } else { + descriptionDisposable.clear(); + } + + // Timestamp — visible when not hiding details + if (!hideDetails && timeDate) { + if (parts.length > 0) { + DOM.append(template.detailsRow, $('span.session-separator.has-separator')); + } + const timeEl = DOM.append(template.detailsRow, $('span.session-time')); + const definiteTimeDate = timeDate; + const formatTime = () => { + const seconds = Math.round((Date.now() - definiteTimeDate.getTime()) / 1000); + return seconds < 60 ? localize('secondsDuration', "now") : fromNow(definiteTimeDate, true); + }; + timeEl.textContent = formatTime(); + const targetWindow = DOM.getWindow(timeEl); + const interval = targetWindow.setInterval(() => { + timeEl.textContent = formatTime(); + }, 60_000); + timeDisposable.value = toDisposable(() => targetWindow.clearInterval(interval)); + } else { + timeDisposable.clear(); + } + })); + + // Approval row — reactive + if (this.approvalModel) { + this.renderApprovalRow(element, template); + } + } + + private renderApprovalRow(element: ISession, template: ISessionItemTemplate): void { + if (!this.approvalModel) { + return; + } + + const approvalModel = this.approvalModel; + const initialInfo = getFirstApprovalAcrossChats(approvalModel, element, undefined); + 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 = getFirstApprovalAcrossChats(approvalModel, element, 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)); + + // Hover with full content as a code block + const fullContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); + buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, { + content: fullContent, + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + })); + + 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, pullRequestIcon?: ThemeIcon): ThemeIcon { + switch (status) { + case SessionStatus.InProgress: return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') }; + case SessionStatus.NeedsInput: return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') }; + case SessionStatus.Error: return { ...Codicon.error, color: themeColorFromId('errorForeground') }; + default: + if (pullRequestIcon) { + return pullRequestIcon; + } + + if (!isRead && !isArchived) { + return { ...Codicon.circleFilled, color: themeColorFromId('textLink.foreground') }; + } + return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + } + + 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; + readonly toolbar: MenuWorkbenchToolBar; + readonly contextKeyService: IContextKeyService; + readonly disposables: DisposableStore; +} + +class SessionSectionRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'session-section'; + readonly templateId = SessionSectionRenderer.TEMPLATE_ID; + + constructor( + private readonly hideSectionCount: boolean, + private readonly instantiationService: IInstantiationService, + private readonly contextKeyService: IContextKeyService, + ) { } + + renderTemplate(container: HTMLElement): ISessionSectionTemplate { + const disposables = new DisposableStore(); + + container.classList.add('session-section'); + const label = DOM.append(container, $('span.session-section-label')); + const count = DOM.append(container, $('span.session-section-count')); + const toolbarContainer = DOM.append(container, $('.session-section-toolbar')); + + const contextKeyService = disposables.add(this.contextKeyService.createScoped(container)); + const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + const toolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, SessionSectionToolbarMenuId, { + menuOptions: { shouldForwardArgs: true }, + })); + + return { container, label, count, toolbar, contextKeyService, disposables }; + } + + 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 = ''; + } + + // Set context key for section type so toolbar actions can use when clauses + const sectionType = element.id.startsWith('workspace:') ? 'workspace' : element.id; + SessionSectionTypeContext.bindTo(template.contextKeyService).set(sectionType); + template.toolbar.context = element; + } + + disposeTemplate(template: ISessionSectionTemplate): void { + template.disposables.dispose(); + } +} + +//#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, preserveFocus: boolean): 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: ISession): void; + unpinSession(session: ISession): void; + isSessionPinned(session: ISession): 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; + setWorkspaceGroupCapped(capped: boolean): void; + isWorkspaceGroupCapped(): 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 WORKSPACE_GROUP_CAPPED_KEY = 'sessionsListControl.workspaceGroupCapped'; + private static readonly WORKSPACE_GROUP_LIMIT = 5; + + private readonly listContainer: HTMLElement; + private readonly tree: WorkbenchObjectTree; + private sessions: ISession[] = []; + private visible = true; + private readonly _pinnedSessionIds: Set; + private readonly excludedSessionTypes: Set; + private readonly excludedStatuses: Set; + private _excludeArchived: boolean; + private _excludeRead: boolean; + private workspaceGroupCapped: boolean; + private readonly expandedWorkspaceGroups = new Set(); + private findOpen = false; + + 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, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @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.workspaceGroupCapped = this.storageService.getBoolean(SessionsList.WORKSPACE_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 hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService)); + const sessionRenderer = new SessionItemRenderer( + { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s) }, + approvalModel, + instantiationService, + contextKeyService, + markdownRendererService, + hoverService, + ); + + const showMoreRenderer = new SessionShowMoreRenderer(); + + const delegate = new SessionsTreeDelegate(approvalModel); + + this.tree = this._register(instantiationService.createInstance( + WorkbenchObjectTree, + 'SessionsListTree', + this.listContainer, + delegate, + [ + sessionRenderer, + new SessionSectionRenderer(true /* hideSectionCount */, instantiationService, contextKeyService), + 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.expandedWorkspaceGroups.add(element.sectionLabel); + this.update(); + return; + } + if (!isSessionSection(element)) { + this.options.onSessionOpen(element.resource, e.editorOptions.preserveFocus ?? false); + } + })); + + this._register(sessionRenderer.onDidChangeItemHeight(session => { + if (this.tree.hasElement(session)) { + this.tree.updateElementHeight(session, delegate.getHeight(session)); + } + })); + + 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.tree.onDidChangeFindOpenState(open => { + this.findOpen = open; + this.update(); + })); + + this._register(this._sessionsManagementService.onDidChangeSessions(() => { + if (this.visible) { + this.refresh(); + } + })); + + // Re-update when the active session changes so that a filtered-out + // session becomes visible while active and hides again when unselected + this._register(autorun(reader => { + this._sessionsManagementService.activeSession.read(reader); + if (this.visible) { + this.update(); + } + })); + + this.refresh(); + } + + refresh(): void { + this.sessions = this._sessionsManagementService.getSessions(); + this.update(); + } + + update(expandAll?: boolean): void { + const activeSession = this._sessionsManagementService.activeSession.get(); + + // 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()); + } + + // Always include the active session even if it was filtered out, + // so it remains visible while selected + if (activeSession && !filtered.some(s => s.sessionId === activeSession.sessionId)) { + const match = this.sessions.find(s => s.sessionId === activeSession.sessionId); + if (match) { + filtered = [...filtered, match]; + } + } + + const sorted = this.sortSessions(filtered); + + // Separate pinned and archived sessions (archived always wins over pinned) + const pinned: ISession[] = []; + const archived: ISession[] = []; + const regular: ISession[] = []; + for (const session of sorted) { + if (session.isArchived.get()) { + archived.push(session); + } else if (this.isSessionPinned(session)) { + pinned.push(session); + } else { + regular.push(session); + } + } + + const grouping = this.options.grouping(); + const sections: ISessionSection[] = []; + + // Group remaining non-archived sessions + const grouped = grouping === SessionsGrouping.Workspace + ? this.groupByWorkspace(regular) + : this.groupByDate(regular); + sections.push(...grouped); + + // Add archived section at the bottom + if (archived.length > 0) { + sections.push({ id: 'archived', label: localize('archived', "Done"), sessions: archived }); + } + + const hasTodaySessions = sections.some(s => s.id === 'today' && s.sessions.length > 0); + + // Pinned sessions appear flat at the top (no section header) + const children: IObjectTreeElement[] = [ + ...pinned.map(session => ({ element: session as SessionListItem })), + ]; + + children.push(...sections.map(section => { + const isWorkspaceGroup = grouping === SessionsGrouping.Workspace + && section.id !== 'archived'; + const isCapped = isWorkspaceGroup && this.workspaceGroupCapped + && !this.findOpen + && !this.expandedWorkspaceGroups.has(section.label) + && section.sessions.length > SessionsList.WORKSPACE_GROUP_LIMIT; + + let sectionChildren: IObjectTreeElement[]; + if (isCapped) { + const visible = section.sessions.slice(0, SessionsList.WORKSPACE_GROUP_LIMIT); + const remainingCount = section.sessions.length - SessionsList.WORKSPACE_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 selection = this.tree.getSelection().filter((s): s is ISession => !!s && !isSessionSection(s) && !isSessionShowMore(s)); + const selectedSessions = selection.includes(element) ? [element, ...selection.filter(s => s !== element)] : [element]; + + const contextOverlay: [string, boolean | string][] = [ + [IsSessionPinnedContext.key, this.isSessionPinned(element)], + [IsSessionArchivedContext.key, element.isArchived.get()], + [IsSessionReadContext.key, element.isRead.get()], + ['chatSessionType', element.sessionType], + [ChatSessionProviderIdContext.key, element.providerId], + ]; + + const menu = this.menuService.createMenu(SessionItemContextMenuId, this.contextKeyService.createOverlay(contextOverlay)); + + this.contextMenuService.showContextMenu({ + getActions: () => Separator.join(...menu.getActions({ arg: selectedSessions, 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: ISession): void { + this._pinnedSessionIds.add(session.sessionId); + this.savePinnedSessions(); + this.update(); + } + + unpinSession(session: ISession): void { + this._pinnedSessionIds.delete(session.sessionId); + this.savePinnedSessions(); + this.update(); + } + + isSessionPinned(session: ISession): 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.workspaceGroupCapped = true; + this.storageService.store(SessionsList.WORKSPACE_GROUP_CAPPED_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + this.expandedWorkspaceGroups.clear(); + this.update(); + } + + // Workspace group capping + + setWorkspaceGroupCapped(capped: boolean): void { + this.workspaceGroupCapped = capped; + this.storageService.store(SessionsList.WORKSPACE_GROUP_CAPPED_KEY, capped, StorageScope.PROFILE, StorageTarget.USER); + if (capped) { + this.expandedWorkspaceGroups.clear(); + } + this.update(); + } + + isWorkspaceGroupCapped(): boolean { + return this.workspaceGroupCapped; + } + + // -- 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: ISession[]): ISession[] { + return sortSessions(sessions, this.options.sorting()); + } + + // -- Grouping -- + + private groupByWorkspace(sessions: ISession[]): ISessionSection[] { + return groupByWorkspace(sessions); + } + + private groupByDate(sessions: ISession[]): ISessionSection[] { + return groupByDate(sessions, this.options.sorting()); + } +} + +//#endregion + +//#region Approval Helpers + +function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { + let oldest: IAgentSessionApprovalInfo | undefined; + for (const chat of session.chats.read(reader)) { + const approval = approvalModel.getApproval(chat.resource).read(reader); + if (approval && (!oldest || approval.since.getTime() < oldest.since.getTime())) { + oldest = approval; + } + } + return oldest; +} + +//#endregion + +//#region Sorting & Grouping Helpers + +export function sortSessions(sessions: ISession[], sorting: SessionsSorting): ISession[] { + return [...sessions].sort((a, b) => { + if (sorting === SessionsSorting.Updated) { + return b.updatedAt.get().getTime() - a.updatedAt.get().getTime(); + } + return b.createdAt.getTime() - a.createdAt.getTime(); + }); +} + +export function groupByWorkspace(sessions: ISession[]): ISessionSection[] { + const groups = new Map(); + for (const session of sessions) { + const workspace = session.workspace.get(); + const label = workspace?.label || localize('unknown', "Unknown"); + let group = groups.get(label); + if (!group) { + group = []; + groups.set(label, group); + } + group.push(session); + } + + const unknownWorkspaceLabel = localize('unknown', "Unknown"); + const order = [...groups.keys()] + .filter(k => k !== unknownWorkspaceLabel) + .sort((a, b) => a.localeCompare(b)); + + const result: ISessionSection[] = order.map(label => ({ + id: `workspace:${label}`, + label, + sessions: groups.get(label)!, + })); + + // "Unknown Workspace" always at the bottom + const unknownWorkspace = groups.get(unknownWorkspaceLabel); + if (unknownWorkspace) { + result.push({ id: `workspace:${unknownWorkspaceLabel}`, label: unknownWorkspaceLabel, sessions: unknownWorkspace }); + } + + return result; +} + +export function groupByDate(sessions: ISession[], sorting: SessionsSorting): 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: ISession[] = []; + const yesterday: ISession[] = []; + const week: ISession[] = []; + const older: ISession[] = []; + + 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: ISession[]) => { + 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..bb6c23f7846c1 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -0,0 +1,450 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { KeybindingLabel } from '../../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { OS } from '../../../../../base/common/platform.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 { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { logSessionsInteraction } from '../../../../common/sessionsTelemetry.js'; + +const $ = DOM.$; +export const SessionsViewId = 'sessions.workbench.view.sessionsView'; +const ACTION_ID_NEW_SESSION = 'workbench.action.sessions.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.Workspace); +export const SessionsViewSortingContext = new RawContextKey('sessionsViewPane.sorting', SessionsSorting.Created); +export const IsWorkspaceGroupCappedContext = new RawContextKey('sessionsViewPane.workspaceGroupCapped', true); + +export class SessionsView extends ViewPane { + + private viewPaneContainer: HTMLElement | undefined; + private sessionsControlContainer: HTMLElement | undefined; + sessionsControl: SessionsList | undefined; + private currentGrouping: SessionsGrouping = SessionsGrouping.Workspace; + private currentSorting: SessionsSorting = SessionsSorting.Created; + private groupingContextKey: IContextKey | undefined; + private sortingContextKey: IContextKey | undefined; + private workspaceGroupCappedContextKey: 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, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + 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); + + // Bind workspace group capped context key (will be synced with persisted state in renderBody) + this.workspaceGroupCappedContextKey = IsWorkspaceGroupCappedContext.bindTo(contextKeyService); + } + + 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, + supportIcons: true, + })); + newSessionButton.label = `$(${Codicon.plus.id}) ${localize('sessionLabel', "Session")}`; + this._register(newSessionButton.onDidClick(() => { + logSessionsInteraction(this.telemetryService, 'newSession'); + this.sessionsManagementService.openNewSessionView(); + })); + + const buttonLabel = $('.new-session-button-label'); + const keybindingHint = $('span.new-session-keybinding-hint'); + const keybindingHintLabel = this._register(new KeybindingLabel(keybindingHint, OS, { + disableTitle: true, + keybindingLabelBackground: 'transparent', + keybindingLabelForeground: 'inherit', + keybindingLabelBorder: 'transparent', + keybindingLabelBottomBorder: undefined, + keybindingLabelShadow: undefined, + })); + DOM.append(buttonLabel, ...Array.from(newSessionButton.element.childNodes)); + DOM.reset(newSessionButton.element, buttonLabel); + + const getNewSessionKeybinding = () => { + const primaryKeybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_SESSION); + const resolvedKeybindings = this.keybindingService.lookupKeybindings(ACTION_ID_NEW_SESSION); + return primaryKeybinding ?? resolvedKeybindings[0]; + }; + + let lastRenderedKeybindingLabel: string | undefined; + let lastRenderedKeybindingAriaLabel: string | undefined; + const updateNewSessionButtonKeybinding = () => { + const keybinding = getNewSessionKeybinding(); + const keybindingLabel = keybinding?.getLabel() ?? undefined; + const keybindingAriaLabel = keybinding?.getAriaLabel() ?? undefined; + if (lastRenderedKeybindingLabel === keybindingLabel && lastRenderedKeybindingAriaLabel === keybindingAriaLabel) { + return; + } + + lastRenderedKeybindingLabel = keybindingLabel; + lastRenderedKeybindingAriaLabel = keybindingAriaLabel; + newSessionButton.element.title = keybindingLabel + ? localize('newSessionButtonTitle', "New Session ({0})", keybindingLabel) + : localize('newSessionButtonTitleWithoutKeybinding', "New Session"); + newSessionButton.element.setAttribute('aria-label', keybindingAriaLabel + ? localize('newSessionButtonAriaLabel', "New Session ({0})", keybindingAriaLabel) + : localize('newSessionButtonAriaLabelWithoutKeybinding', "New Session")); + + DOM.reset(newSessionButton.element, buttonLabel); + keybindingHintLabel.set(keybinding); + if (keybinding) { + DOM.append(newSessionButton.element, keybindingHint); + } + }; + this._register(Event.runAndSubscribe(this.keybindingService.onDidUpdateKeybindings, updateNewSessionButtonKeybinding)); + + // 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, preserveFocus) => this.sessionsManagementService.openSession(resource, { preserveFocus }), + })); + this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); + + // Sync workspace group capped context key with persisted state + this.workspaceGroupCappedContextKey?.set(sessionsControl.isWorkspaceGroupCapped()); + + // 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, { + onDidChangeLayout: () => { + 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', "Done"), + 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; + const workspaceGroupCappedContextKey = this.workspaceGroupCappedContextKey; + 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()); + } + workspaceGroupCappedContextKey?.set(sessionsControl.isWorkspaceGroupCapped()); + } + })); + } + + 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..c22efe04ac62b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -0,0 +1,754 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize, 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 { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { EditorsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.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 { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, IsSessionArchivedContext, IsSessionReadContext, SessionsGrouping, SessionsSorting, ISessionSection } from './sessionsList.js'; +import { ISessionsManagementService, ActiveSessionSupportsMultiChatContext } from '../sessionsManagementService.js'; +import { ISession, SessionStatus } from '../../common/sessionData.js'; +import { IsWorkspaceGroupCappedContext, SessionsViewFilterOptionsSubMenu, SessionsViewFilterSubMenu, SessionsViewGroupingContext, SessionsViewId, SessionsView, SessionsViewSortingContext } from './sessionsView.js'; +import { SessionsViewId as NewChatViewId, NewChatViewPane } from '../../../chat/browser/newChatViewPane.js'; +import { Menus } from '../../../../browser/menus.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 GroupByWorkspaceAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.groupByWorkspace', + title: localize2('groupByWorkspace', "Group by Workspace"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Workspace), + 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.Workspace); + } +}); + +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); + } +}); + +// Workspace Group Capping + +registerAction2(class ShowRecentWorkspaceSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.showRecentSessions', + title: localize2('showRecentSessions', "Show Recent Sessions"), + category: SessionsCategories.Sessions, + toggled: IsWorkspaceGroupCappedContext, + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_cap', + order: 0, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Workspace), + }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.setWorkspaceGroupCapped(true); + IsWorkspaceGroupCappedContext.bindTo(accessor.get(IContextKeyService)).set(true); + } +}); + +registerAction2(class ShowAllWorkspaceSessionsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.showAllSessions', + title: localize2('showAllSessions', "Show All Sessions"), + category: SessionsCategories.Sessions, + toggled: IsWorkspaceGroupCappedContext.negate(), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_cap', + order: 1, + when: ContextKeyExpr.equals(SessionsViewGroupingContext.key, SessionsGrouping.Workspace), + }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.setWorkspaceGroupCapped(false); + IsWorkspaceGroupCappedContext.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 NewSessionForWorkspaceAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.sectionNewSession', + title: localize2('newSessionForWorkspace', "New Session"), + icon: Codicon.plus, + menu: [{ + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 0, + when: ContextKeyExpr.equals(SessionSectionTypeContext.key, 'workspace'), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { + if (!context || !context.sessions || context.sessions.length === 0) { + return; + } + const sessionsManagementService = accessor.get(ISessionsManagementService); + const viewsService = accessor.get(IViewsService); + sessionsManagementService.openNewSessionView(); + const view = await viewsService.openView(NewChatViewId, true); + const workspace = context.sessions[0].workspace.get(); + if (view && workspace) { + view.selectWorkspace({ providerId: context.sessions[0].providerId, workspace }); + } + } +}); + +const ConfirmArchiveStorageKey = 'sessions.confirmArchive'; + +registerAction2(class ArchiveSectionAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.sectionArchive', + title: localize2('archiveSection', "Mark All as Done"), + icon: Codicon.check, + menu: [{ + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.notEquals(SessionSectionTypeContext.key, 'archived'), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { + if (!context || !context.sessions || context.sessions.length === 0) { + return; + } + + const sessionsManagementService = accessor.get(ISessionsManagementService); + const dialogService = accessor.get(IDialogService); + const storageService = accessor.get(IStorageService); + + const skipConfirmation = storageService.getBoolean(ConfirmArchiveStorageKey, StorageScope.PROFILE, false); + if (!skipConfirmation) { + const confirmed = await dialogService.confirm({ + message: context.sessions.length === 1 + ? localize('archiveSectionSessions.confirmSingle', "Are you sure you want to mark 1 session from '{0}' as done?", context.label) + : localize('archiveSectionSessions.confirm', "Are you sure you want to mark {0} sessions from '{1}' as done?", context.sessions.length, context.label), + detail: localize('archiveSectionSessions.detail', "You can restore sessions later if needed from the sessions view."), + primaryButton: localize('archiveSectionSessions.archive', "Mark All as Done"), + checkbox: { + label: localize('doNotAskAgain', "Do not ask me again") + } + }); + + if (!confirmed.confirmed) { + return; + } + + if (confirmed.checkboxChecked) { + storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER); + } + } + + for (const session of context.sessions) { + await sessionsManagementService.archiveSession(session); + } + } +}); + +registerAction2(class UnarchiveSectionAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.sectionUnarchive', + title: localize2('unarchiveSection', "Restore All"), + icon: Codicon.discard, + menu: [{ + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 1, + when: ContextKeyExpr.equals(SessionSectionTypeContext.key, 'archived'), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISessionSection): Promise { + if (!context || !context.sessions || context.sessions.length === 0) { + return; + } + + const sessionsManagementService = accessor.get(ISessionsManagementService); + const dialogService = accessor.get(IDialogService); + const storageService = accessor.get(IStorageService); + + if (context.sessions.length > 1) { + const skipConfirmation = storageService.getBoolean(ConfirmArchiveStorageKey, StorageScope.PROFILE, false); + if (!skipConfirmation) { + const confirmed = await dialogService.confirm({ + message: localize('unarchiveSectionSessions.confirm', "Are you sure you want to restore {0} sessions?", context.sessions.length), + primaryButton: localize('unarchiveSectionSessions.unarchive', "Restore All"), + checkbox: { + label: localize('doNotAskAgain2', "Do not ask me again") + } + }); + + if (!confirmed.confirmed) { + return; + } + + if (confirmed.checkboxChecked) { + storageService.store(ConfirmArchiveStorageKey, true, StorageScope.PROFILE, StorageTarget.USER); + } + } + } + + for (const session of context.sessions) { + await sessionsManagementService.unarchiveSession(session); + } + } +}); + +// 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: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionPinnedContext.key, false), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }, { + id: SessionItemContextMenuId, + group: '0_pin', + order: 0, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionPinnedContext.key, false), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISession | ISession[]): void { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + for (const session of sessions) { + view?.sessionsControl?.pinSession(session); + } + } +}); + +registerAction2(class UnpinSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.unpinSession', + title: localize2('unpinSession', "Unpin"), + icon: Codicon.pinned, + menu: [{ + id: SessionItemToolbarMenuId, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionPinnedContext.key, true), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }, { + id: SessionItemContextMenuId, + group: '0_pin', + order: 0, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(IsSessionPinnedContext.key, true), + ContextKeyExpr.equals(IsSessionArchivedContext.key, false), + ), + }] + }); + } + run(accessor: ServicesAccessor, context?: ISession | ISession[]): void { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + for (const session of sessions) { + view?.sessionsControl?.unpinSession(session); + } + } +}); + +registerAction2(class ArchiveSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.archiveSession', + title: localize2('archiveSession', "Mark as Done"), + icon: Codicon.check, + 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?: ISession | ISession[]): Promise { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const sessionsManagementService = accessor.get(ISessionsManagementService); + for (const session of sessions) { + await sessionsManagementService.archiveSession(session); + } + } +}); + +registerAction2(class UnarchiveSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.unarchiveSession', + title: localize2('unarchiveSession', "Restore"), + icon: Codicon.discard, + 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?: ISession | ISession[]): Promise { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const sessionsManagementService = accessor.get(ISessionsManagementService); + for (const session of sessions) { + await sessionsManagementService.unarchiveSession(session); + } + } +}); + +registerAction2(class RenameSessionAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.renameSession', + title: localize2('renameSession', "Rename..."), + menu: [{ + id: SessionItemContextMenuId, + group: '1_edit', + order: 1, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, /^agenthost-/), + }] + }); + } + async run(accessor: ServicesAccessor, context?: ISession | ISession[]): Promise { + const session = Array.isArray(context) ? context[0] : context; + if (!session) { + return; + } + const quickInputService = accessor.get(IQuickInputService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const newTitle = await quickInputService.input({ + value: session.title.get(), + prompt: localize('renameSession.prompt', "New agent session title"), + validateInput: async value => { + if (!value.trim()) { + return localize('renameSession.empty', "Title cannot be empty"); + } + return undefined; + } + }); + if (newTitle) { + const trimmedTitle = newTitle.trim(); + if (trimmedTitle) { + await sessionsManagementService.renameChat(session, session.mainChat.resource, trimmedTitle); + } + } + } +}); + +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?: ISession | ISession[]): void { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const sessionsManagementService = accessor.get(ISessionsManagementService); + for (const session of sessions) { + sessionsManagementService.setRead(session, 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?: ISession | ISession[]): void { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const sessionsManagementService = accessor.get(ISessionsManagementService); + for (const session of sessions) { + sessionsManagementService.setRead(session, 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?: ISession | ISession[]): Promise { + if (!context) { + return; + } + const sessions = Array.isArray(context) ? context : [context]; + const chatWidgetService = accessor.get(IChatWidgetService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + + sessionsManagementService.openNewSessionView(); // running this first to address focus issues + + for (const session of sessions) { + await chatWidgetService.openSession(session.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: 103, + when: ContextKeyExpr.and( + IsAuxiliaryWindowContext.negate(), + SessionsWelcomeVisibleContext.negate(), + IsNewChatSessionContext.negate() + ) + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + ContextKeyExpr.or( + ContextKeyExpr.and( + ContextKeyExpr.equals('sessions.hasGitRepository', true), + ContextKeyExpr.equals('sessions.hasPullRequest', false), + ContextKeyExpr.equals('sessions.hasOutgoingChanges', false), + ContextKeyExpr.equals('sessions.hasUncommittedChanges', false), + ), + ContextKeyExpr.and( + ContextKeyExpr.equals('sessions.hasGitRepository', true), + ContextKeyExpr.equals('sessions.hasPullRequest', true), + ContextKeyExpr.equals('sessions.hasOpenPullRequest', false), + ) + ) + ) + }] + }); + } + + 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); + } +}); + +registerAction2(class AddChatAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.addChat', + title: localize2('addChat', "Add Chat"), + icon: Codicon.plus, + menu: [{ + id: Menus.CommandCenter, + order: 102, + when: ContextKeyExpr.and( + IsAuxiliaryWindowContext.negate(), + SessionsWelcomeVisibleContext.negate(), + IsNewChatSessionContext.negate(), + ActiveSessionSupportsMultiChatContext + ) + }] + }); + } + + async run(accessor: ServicesAccessor): Promise { + const sessionsManagementService = accessor.get(ISessionsManagementService); + const quickInputService = accessor.get(IQuickInputService); + + const activeSession = sessionsManagementService.activeSession.get(); + if (!activeSession || activeSession.status.get() === SessionStatus.Untitled) { + return; + } + + const query = await quickInputService.input({ + placeHolder: localize('addChat.placeholder', "Enter a prompt for the new chat"), + prompt: localize('addChat.prompt', "Add a new chat to the active session"), + }); + + if (query) { + await sessionsManagementService.sendAndCreateChat(activeSession, { query }); + } + } +}); 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..6f8a3bf56bc38 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/common/sessionData.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IMarkdownString } from '../../../../base/common/htmlContent.js'; +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; + /** Name of the base branch. */ + readonly baseBranchName: 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[]; + /** Whether the session requires workspace trust to operate. */ + readonly requiresWorkspaceTrust: boolean; +} + +/** + * GitHub information associated with a session. + */ +export interface IGitHubInfo { + /** GitHub repository owner. */ + readonly owner: string; + /** GitHub repository name. */ + readonly repo: string; + /** Pull request associated with this session, if any. */ + readonly pullRequest?: { + /** Pull request number. */ + readonly number: number; + /** URI of the pull request. */ + readonly uri: URI; + /** Icon reflecting the PR state. */ + readonly icon?: ThemeIcon; + }; +} + +/** + * A single chat within a session, produced by the sessions management layer. + */ +export interface IChat { + /** Resource URI identifying this chat. */ + readonly resource: URI; + /** When the chat was created. */ + readonly createdAt: Date; + + // Reactive properties + + /** Chat display title (changes when auto-titled or renamed). */ + readonly title: IObservable; + /** When the chat was last updated. */ + readonly updatedAt: IObservable; + /** Current chat status. */ + readonly status: IObservable; + /** File changes produced by the chat. */ + 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 chat is archived. */ + readonly isArchived: IObservable; + /** Whether the chat has been read. */ + readonly isRead: IObservable; + /** Status description shown while the chat is active (e.g., current agent action). */ + readonly description: IObservable; + /** Timestamp of when the last agent turn ended, if any. */ + readonly lastTurnEnd: IObservable; +} + +/** + * A session groups one or more chats together. + * All {@link ISessionData} fields are propagated from the primary (first) chat. + */ +export interface ISession { + /** 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; + /** GitHub information associated with this session, if any. */ + readonly gitHubInfo: IObservable; + /** The chats belonging to this session group. */ + readonly chats: IObservable; + /** The main (first) chat of this session. */ + readonly mainChat: IChat; +} 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..619ae7930addd --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IActiveSession, ISessionsManagementService } from '../../browser/sessionsManagementService.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) => ({ + uri: fakeUri('prompt', i), + name: `prompt-${i}`, + type: PromptsType.prompt, + storage: PromptsStorage.local, + userInvocable: true, + parsedPromptFile: undefined, + when: undefined, + })); + 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..b27a2a6bd101c --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -0,0 +1,801 @@ +/*--------------------------------------------------------------------------------------------- + * 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, IAgentInstructionFile, AgentInstructionFileType } 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): IAgentInstructionFile { + return { uri: URI.file(path), realPath: undefined, type: AgentInstructionFileType.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?: IAgentInstructionFile[]; + 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 => ({ + uri: c.uri, + name: c.name, + type: c.type, + storage: c.storage, + userInvocable: true, + parsedPromptFile: undefined!, + when: undefined, + })), + 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/sessions/test/browser/sessionsGroupModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsGroupModel.test.ts new file mode 100644 index 0000000000000..53d8afb3a5c00 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsGroupModel.test.ts @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { SessionsGroupModel } from '../../browser/sessionsGroupModel.js'; + +const STORAGE_KEY = 'sessions.groups'; + +suite('SessionsGroupModel', () => { + + let store: DisposableStore; + let storageService: InMemoryStorageService; + + setup(() => { + store = new DisposableStore(); + storageService = store.add(new InMemoryStorageService()); + }); + + teardown(() => { + store.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createModel(): SessionsGroupModel { + return store.add(new SessionsGroupModel(storageService)); + } + + test('starts empty', () => { + const model = createModel(); + assert.deepStrictEqual(model.getSessionIds(), []); + }); + + test('addChat creates group with first chat as active', () => { + const model = createModel(); + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.addChat('s1', 'c1'); + + assert.deepStrictEqual(model.getSessionIds(), ['s1']); + assert.deepStrictEqual(model.getChatIds('s1'), ['c1']); + assert.strictEqual(model.getActiveChatId('s1'), 'c1'); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('addChat appends to existing group preserving active', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.addChat('s1', 'c2'); + + assert.deepStrictEqual(model.getChatIds('s1'), ['c1', 'c2']); + assert.strictEqual(model.getActiveChatId('s1'), 'c1'); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('addChat is a no-op for duplicate chat', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.addChat('s1', 'c1'); + + assert.deepStrictEqual(model.getChatIds('s1'), ['c1']); + assert.deepStrictEqual(fired, []); + }); + + test('getChatIds returns empty for unknown session', () => { + const model = createModel(); + assert.deepStrictEqual(model.getChatIds('unknown'), []); + }); + + test('getSessionIdForChat finds correct session', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s2', 'c2'); + + assert.strictEqual(model.getSessionIdForChat('c1'), 's1'); + assert.strictEqual(model.getSessionIdForChat('c2'), 's2'); + }); + + test('getSessionIdForChat returns undefined for unknown chat', () => { + const model = createModel(); + assert.strictEqual(model.getSessionIdForChat('x'), undefined); + }); + + test('getActiveChatId throws for unknown session', () => { + const model = createModel(); + assert.throws(() => model.getActiveChatId('x')); + }); + + test('setActiveChatId changes active chat and fires event', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.setActiveChatId('c2'); + + assert.strictEqual(model.getActiveChatId('s1'), 'c2'); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('setActiveChatId is a no-op for chat not in any group', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.setActiveChatId('c999'); + + assert.strictEqual(model.getActiveChatId('s1'), 'c1'); + assert.deepStrictEqual(fired, []); + }); + + test('setActiveChatId is a no-op when already active', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.setActiveChatId('c1'); + + assert.deepStrictEqual(fired, []); + }); + + test('removeChat removes chat from its group', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.removeChat('c1'); + + assert.deepStrictEqual(model.getChatIds('s1'), ['c2']); + assert.strictEqual(model.getSessionIdForChat('c1'), undefined); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('removeChat deletes group when last chat is removed', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.removeChat('c1'); + + assert.deepStrictEqual(model.getSessionIds(), []); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('removeChat adjusts active index when active chat is removed', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + model.addChat('s1', 'c3'); + model.setActiveChatId('c3'); + + model.removeChat('c3'); + + assert.strictEqual(model.getActiveChatId('s1'), 'c2'); + }); + + test('removeChat adjusts active index when earlier chat is removed', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + model.addChat('s1', 'c3'); + model.setActiveChatId('c3'); + + model.removeChat('c1'); + + assert.strictEqual(model.getActiveChatId('s1'), 'c3'); + }); + + test('removeChat preserves active when later chat is removed', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + model.addChat('s1', 'c3'); + model.setActiveChatId('c1'); + + model.removeChat('c3'); + + assert.strictEqual(model.getActiveChatId('s1'), 'c1'); + }); + + test('removeChat is a no-op for unknown chat', () => { + const model = createModel(); + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.removeChat('x'); + + assert.deepStrictEqual(fired, []); + }); + + test('deleteSession removes group entirely', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.deleteSession('s1'); + + assert.deepStrictEqual(model.getSessionIds(), []); + assert.deepStrictEqual(model.getChatIds('s1'), []); + assert.strictEqual(model.getSessionIdForChat('c1'), undefined); + assert.deepStrictEqual(fired, ['s1']); + }); + + test('deleteSession is a no-op for unknown session', () => { + const model = createModel(); + const fired: string[] = []; + store.add(model.onDidChange(e => fired.push(e.sessionId))); + + model.deleteSession('x'); + + assert.deepStrictEqual(fired, []); + }); + + test('data persists across instances via storage', () => { + const model1 = createModel(); + model1.addChat('s1', 'c1'); + model1.addChat('s1', 'c2'); + model1.addChat('s2', 'c3'); + model1.setActiveChatId('c2'); + model1.dispose(); + + const model2 = createModel(); + assert.deepStrictEqual(model2.getSessionIds(), ['s1', 's2']); + assert.deepStrictEqual(model2.getChatIds('s1'), ['c1', 'c2']); + assert.deepStrictEqual(model2.getChatIds('s2'), ['c3']); + assert.strictEqual(model2.getActiveChatId('s1'), 'c2'); + assert.strictEqual(model2.getActiveChatId('s2'), 'c3'); + }); + + test('deletion persists across instances', () => { + const model1 = createModel(); + model1.addChat('s1', 'c1'); + model1.deleteSession('s1'); + model1.dispose(); + + const model2 = createModel(); + assert.deepStrictEqual(model2.getSessionIds(), []); + }); + + test('removeChat last-chat deletion persists', () => { + const model1 = createModel(); + model1.addChat('s1', 'c1'); + model1.removeChat('c1'); + model1.dispose(); + + const model2 = createModel(); + assert.deepStrictEqual(model2.getSessionIds(), []); + }); + + test('handles invalid JSON in storage gracefully', () => { + storageService.store(STORAGE_KEY, '{bad json', StorageScope.PROFILE, StorageTarget.MACHINE); + const model = createModel(); + assert.deepStrictEqual(model.getSessionIds(), []); + }); + + test('handles non-array JSON in storage gracefully', () => { + storageService.store(STORAGE_KEY, '{"not":"array"}', StorageScope.PROFILE, StorageTarget.MACHINE); + const model = createModel(); + assert.deepStrictEqual(model.getSessionIds(), []); + }); + + test('handles malformed entries in storage gracefully', () => { + const data = [ + { sessionId: 'good', chatIds: ['c1'], activeChatIndex: 0 }, + { sessionId: 123, chatIds: ['c2'] }, // bad sessionId type + { chatIds: ['c3'] }, // missing sessionId + { sessionId: 'mixed', chatIds: ['ok', 42] }, // mixed chatIds types + { sessionId: 'empty', chatIds: [] }, // empty chatIds skipped + null, // null entry + ]; + storageService.store(STORAGE_KEY, JSON.stringify(data), StorageScope.PROFILE, StorageTarget.MACHINE); + + const model = createModel(); + + assert.deepStrictEqual(model.getSessionIds(), ['good', 'mixed']); + assert.deepStrictEqual(model.getChatIds('good'), ['c1']); + assert.deepStrictEqual(model.getChatIds('mixed'), ['ok']); + assert.strictEqual(model.getActiveChatId('good'), 'c1'); + assert.strictEqual(model.getActiveChatId('mixed'), 'ok'); + }); + + test('handles invalid activeChatIndex in storage gracefully', () => { + const data = [ + { sessionId: 's1', chatIds: ['c1', 'c2'], activeChatIndex: 1 }, + { sessionId: 's2', chatIds: ['c3'], activeChatIndex: 5 }, // out of range + { sessionId: 's3', chatIds: ['c4'], activeChatIndex: -1 }, // negative + { sessionId: 's4', chatIds: ['c5'] }, // missing + ]; + storageService.store(STORAGE_KEY, JSON.stringify(data), StorageScope.PROFILE, StorageTarget.MACHINE); + + const model = createModel(); + + assert.strictEqual(model.getActiveChatId('s1'), 'c2'); + assert.strictEqual(model.getActiveChatId('s2'), 'c3'); + assert.strictEqual(model.getActiveChatId('s3'), 'c4'); + assert.strictEqual(model.getActiveChatId('s4'), 'c5'); + }); + + test('removeChat updates storage', () => { + const model = createModel(); + model.addChat('s1', 'c1'); + model.addChat('s1', 'c2'); + model.removeChat('c1'); + model.dispose(); + + const model2 = createModel(); + assert.deepStrictEqual(model2.getChatIds('s1'), ['c2']); + }); +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts new file mode 100644 index 0000000000000..ba4eda4bfb229 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IChat, ISession, SessionStatus } from '../../common/sessionData.js'; +import { groupByWorkspace, sortSessions, SessionsSorting } from '../../browser/views/sessionsList.js'; + +function createSession(id: string, opts: { + workspaceLabel?: string; + createdAt?: Date; + updatedAt?: Date; + isArchived?: boolean; +}): ISession { + const createdAt = opts.createdAt ?? new Date(); + const updatedAt = opts.updatedAt ?? createdAt; + return { + sessionId: id, + resource: URI.parse(`session://${id}`), + providerId: 'test', + sessionType: 'test', + icon: Codicon.account, + createdAt, + workspace: observableValue(`workspace-${id}`, opts.workspaceLabel !== undefined ? { + label: opts.workspaceLabel, + icon: Codicon.folder, + repositories: [], + requiresWorkspaceTrust: false, + } : undefined), + title: observableValue(`title-${id}`, id), + updatedAt: observableValue(`updatedAt-${id}`, updatedAt), + status: observableValue(`status-${id}`, SessionStatus.Completed), + changes: observableValue(`changes-${id}`, []), + modelId: observableValue(`modelId-${id}`, undefined), + mode: observableValue(`mode-${id}`, undefined), + loading: observableValue(`loading-${id}`, false), + isArchived: observableValue(`isArchived-${id}`, opts.isArchived ?? false), + isRead: observableValue(`isRead-${id}`, true), + description: observableValue(`description-${id}`, undefined), + lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), + gitHubInfo: observableValue(`gitHubInfo-${id}`, undefined), + chats: observableValue(`chats-${id}`, []), + mainChat: undefined!, + }; +} + +suite('Sessions - SessionsList Helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('groupByWorkspace', () => { + + test('groups are sorted alphabetically regardless of insertion order', () => { + const sessions = [ + createSession('1', { workspaceLabel: 'Zebra' }), + createSession('2', { workspaceLabel: 'Apple' }), + createSession('3', { workspaceLabel: 'Mango' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.deepStrictEqual(groups.map(g => g.label), ['Apple', 'Mango', 'Zebra']); + }); + + test('sessions without workspace are grouped under "Unknown"', () => { + const sessions = [ + createSession('1', { workspaceLabel: 'Beta' }), + createSession('2', {}), + createSession('3', { workspaceLabel: 'Alpha' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.deepStrictEqual(groups.map(g => g.label), ['Alpha', 'Beta', 'Unknown']); + }); + + test('multiple sessions in same workspace are grouped together', () => { + const sessions = [ + createSession('1', { workspaceLabel: 'Repo-B' }), + createSession('2', { workspaceLabel: 'Repo-A' }), + createSession('3', { workspaceLabel: 'Repo-B' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.deepStrictEqual(groups.map(g => g.label), ['Repo-A', 'Repo-B']); + assert.strictEqual(groups[0].sessions.length, 1); + assert.strictEqual(groups[1].sessions.length, 2); + }); + + test('"No Workspace" appears after workspaces that sort alphabetically later', () => { + const sessions = [ + createSession('1', {}), + createSession('2', { workspaceLabel: 'Zulu' }), + createSession('3', { workspaceLabel: 'Alpha' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.deepStrictEqual(groups.map(g => g.label), ['Alpha', 'Zulu', 'Unknown']); + }); + + test('empty workspace label is treated as "Unknown"', () => { + const sessions = [ + createSession('1', { workspaceLabel: 'Zulu' }), + createSession('2', { workspaceLabel: '' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.deepStrictEqual(groups.map(g => g.label), ['Zulu', 'Unknown']); + assert.strictEqual(groups[1].sessions.length, 1); + }); + + test('group ids are prefixed with workspace:', () => { + const sessions = [ + createSession('1', { workspaceLabel: 'MyProject' }), + ]; + + const groups = groupByWorkspace(sessions); + + assert.strictEqual(groups[0].id, 'workspace:MyProject'); + }); + }); + + suite('sortSessions', () => { + + test('sorts by createdAt descending when sorting is Created', () => { + const sessions = [ + createSession('old', { createdAt: new Date('2024-01-01') }), + createSession('new', { createdAt: new Date('2024-06-01') }), + createSession('mid', { createdAt: new Date('2024-03-01') }), + ]; + + const sorted = sortSessions(sessions, SessionsSorting.Created); + + assert.deepStrictEqual(sorted.map(s => s.sessionId), ['new', 'mid', 'old']); + }); + + test('sorts by updatedAt descending when sorting is Updated', () => { + const sessions = [ + createSession('a', { createdAt: new Date('2024-06-01'), updatedAt: new Date('2024-07-01') }), + createSession('b', { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-09-01') }), + createSession('c', { createdAt: new Date('2024-03-01'), updatedAt: new Date('2024-08-01') }), + ]; + + const sorted = sortSessions(sessions, SessionsSorting.Updated); + + assert.deepStrictEqual(sorted.map(s => s.sessionId), ['b', 'c', 'a']); + }); + }); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index ff97b6ae70c44..b1aa08c9305b9 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -8,128 +8,292 @@ 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 { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ISession } from '../../sessions/common/sessionData.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logSessionsInteraction } from '../../../common/sessionsTelemetry.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'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { CopilotCLISessionType } from '../../sessions/browser/sessionTypes.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: ISession | undefined): URI | undefined { + if (session?.sessionType !== CopilotCLISessionType.id) { + return undefined; + } + const repo = session.workspace.get()?.repositories[0]; + const cwd = repo?.workingDirectory ?? repo?.uri; + if (cwd?.scheme === AGENT_HOST_SCHEME) { + return undefined; } - return session?.repository; + return cwd; } /** * 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: ISession | undefined): Promise { + if (!session) { + return; + } + + const sessionCwd = getSessionCwd(session); + + const targetPath = sessionCwd ?? await this._pathService.userHome(); + const targetKey = targetPath.fsPath.toLowerCase(); + if (this._activeKey === targetKey) { return; } + this._activeKey = targetKey; - const targetFsPath = targetPath.fsPath; - if (this._lastTargetFsPath?.toLowerCase() === targetFsPath.toLowerCase()) { + 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; } - this._lastTargetFsPath = targetFsPath; + await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId)); + } - await this.ensureTerminal(targetPath, false); + /** + * 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; } - private _closeTerminalsForPath(fsPath: string): void { + 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; + } + + /** + * 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 +307,35 @@ 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() + order: 10, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), }] }); } override async run(_accessor: ServicesAccessor): Promise { + const telemetryService = _accessor.get(ITelemetryService); + logSessionsInteraction(telemetryService, 'openTerminal'); + + 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 +343,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..ec55328bb27f8 --- /dev/null +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.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 { 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 { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { IChat, ISession } from '../../../sessions/common/sessionData.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; +}): IActiveSession { + const repo = opts.repository || opts.worktree ? { + uri: opts.repository ?? opts.worktree!, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchName: undefined, + baseBranchProtected: undefined, + } : undefined; + const chat: IChat = { + resource: URI.parse('file:///session'), + createdAt: new Date(), + 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), + isArchived: observableValue('test.isArchived', opts.isArchived ?? false), + isRead: observableValue('test.isRead', true), + lastTurnEnd: observableValue('test.lastTurnEnd', undefined), + description: observableValue('test.description', undefined), + }; + const session: IActiveSession = { + sessionId: 'test:session', + resource: chat.resource, + providerId: 'test', + sessionType: opts.providerType ?? AgentSessionProviders.Local, + icon: Codicon.copilot, + createdAt: chat.createdAt, + workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined), + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + loading: observableValue('test.loading', false), + isArchived: chat.isArchived, + isRead: chat.isRead, + lastTurnEnd: chat.lastTurnEnd, + description: chat.description, + gitHubInfo: observableValue('test.gitHubInfo', undefined), + chats: observableValue('test.chats', [chat]), + activeChat: observableValue('test.activeChat', chat), + mainChat: chat, + }; + return session; +} + +function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerType?: string }): ISession { + const repo = opts.repository || opts.worktree ? { + uri: opts.repository ?? opts.worktree!, + workingDirectory: opts.worktree, + detail: undefined, + baseBranchName: undefined, + baseBranchProtected: undefined, + } : undefined; + const chat: IChat = { + resource: URI.parse('file:///session'), + createdAt: new Date(), + 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), + isArchived: observableValue('test.isArchived', false), + isRead: observableValue('test.isRead', true), + lastTurnEnd: observableValue('test.lastTurnEnd', undefined), + description: observableValue('test.description', undefined), + }; + const session: ISession = { + sessionId: 'test:non-agent', + resource: chat.resource, + providerId: 'test', + sessionType: opts.providerType ?? AgentSessionProviders.Local, + icon: Codicon.copilot, + createdAt: chat.createdAt, + workspace: observableValue('test.workspace', repo ? { label: 'test', icon: Codicon.repo, repositories: [repo], requiresWorkspaceTrust: false, } : undefined), + title: chat.title, + updatedAt: chat.updatedAt, + status: chat.status, + changes: chat.changes, + modelId: chat.modelId, + mode: chat.mode, + loading: observableValue('test.loading', false), + isArchived: chat.isArchived, + isRead: chat.isRead, + lastTurnEnd: chat.lastTurnEnd, + description: chat.description, + gitHubInfo: observableValue('test.gitHubInfo', undefined), + chats: observableValue('test.chats', [chat]), + mainChat: chat, + }; + return session; +} + +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 as IActiveSession, 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'); + }); + + // --- Remote agent host sessions --- + + test('falls back to home directory for a background session with a remote agent host repository', async () => { + const remoteRepoUri = toAgentHostUri(URI.file('/Users/user/repo'), 'my-server'); + const session = makeAgentSession({ repository: remoteRepoUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the home directory'); + assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/src/vs/sessions/contrib/welcome/browser/media/apple-dark.svg b/src/vs/sessions/contrib/welcome/browser/media/apple-dark.svg new file mode 100644 index 0000000000000..59346e8435b99 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/apple-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/sessions/contrib/welcome/browser/media/apple-light.svg b/src/vs/sessions/contrib/welcome/browser/media/apple-light.svg new file mode 100644 index 0000000000000..a8ea3e2d9afb2 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/apple-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/sessions/contrib/welcome/browser/media/github-mark.svg b/src/vs/sessions/contrib/welcome/browser/media/github-mark.svg new file mode 100644 index 0000000000000..c679c236fd224 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/github-mark.svg @@ -0,0 +1 @@ + diff --git a/src/vs/sessions/contrib/welcome/browser/media/google.svg b/src/vs/sessions/contrib/welcome/browser/media/google.svg new file mode 100644 index 0000000000000..060954624495e --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-dark.svg b/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-dark.svg new file mode 100644 index 0000000000000..fdbb874502de1 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-light.svg b/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-light.svg new file mode 100644 index 0000000000000..29dfd5459d13c --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/sessions-logo-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css new file mode 100644 index 0000000000000..c195fe0146385 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/sessionsWalkthrough.css @@ -0,0 +1,387 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Multi-step Walkthrough Overlay ---- */ + +.sessions-walkthrough-overlay { + position: absolute; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-editor-background); + opacity: 1; + transition: opacity 200ms ease-out; +} + +.sessions-walkthrough-overlay .hidden { + display: none; +} + +/* Hide titlebar right actions during sign-in */ +.monaco-workbench:has(.sessions-walkthrough-overlay) .part.titlebar .titlebar-right { + visibility: hidden; +} + +.sessions-walkthrough-overlay.sessions-walkthrough-dismissed { + opacity: 0; + pointer-events: none; +} + +/* ---- Card ---- */ + +.sessions-walkthrough-card { + display: flex; + flex-direction: column; + width: 640px; + max-width: calc(100vw - 64px); + max-height: calc(100vh - 80px); + text-align: left; + gap: 24px; +} + +/* ---- Content area (scrollable middle) ---- */ + +.sessions-walkthrough-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + width: 100%; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + padding: 4px 0 8px; + box-sizing: border-box; + opacity: 1; + transition: opacity 200ms ease-out; +} + +.sessions-walkthrough-content.sessions-walkthrough-fade-out { + opacity: 0; +} + +.sessions-walkthrough-content.sessions-walkthrough-fade-in { + opacity: 1; +} + +/* Progress bar (replaces provider buttons during setup) */ +.sessions-walkthrough-progress-bar { + width: 100%; + height: 3px; + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + border-radius: 2px; + overflow: hidden; + margin-top: 16px; +} + +.sessions-walkthrough-progress-bar-fill { + width: 30%; + height: 100%; + background: var(--vscode-progressBar-background, #0078d4); + border-radius: 2px; + animation: sessions-walkthrough-progress 2s ease-in-out infinite; +} + +@keyframes sessions-walkthrough-progress { + 0% { + transform: translateX(0%); + } + + 50% { + transform: translateX(233%); + } + + 100% { + transform: translateX(0%); + } +} + +/* ---- Fixed footer (always at bottom) ---- */ + +.sessions-walkthrough-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + width: 100%; + flex-shrink: 0; +} + +.sessions-walkthrough-success-actions { + display: flex; + flex-direction: row; + gap: 8px; + margin-top: 8px; +} + +/* ---- Sign-in provider buttons ---- */ + +.sessions-walkthrough-provider-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 16px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--vscode-foreground) 15%, transparent); + background: transparent; + color: var(--vscode-foreground); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 100ms, border-color 100ms; + white-space: nowrap; +} + +.sessions-walkthrough-provider-label { + line-height: 1; +} + +.sessions-walkthrough-provider-btn:hover { + background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); + border-color: color-mix(in srgb, var(--vscode-foreground) 25%, transparent); +} + +.sessions-walkthrough-provider-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.sessions-walkthrough-provider-btn::before { + content: ''; + width: 18px; + height: 18px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + flex-shrink: 0; +} + +.sessions-walkthrough-provider-btn.provider-github::before { + -webkit-mask-image: url('./github-mark.svg'); + mask-image: url('./github-mark.svg'); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; + background-color: currentColor; +} + +.sessions-walkthrough-provider-btn.provider-google::before { + background-image: url('./google.svg'); +} + +.sessions-walkthrough-provider-btn.provider-apple::before { + background-image: url('./apple-dark.svg'); +} + +.monaco-workbench.hc-light .sessions-walkthrough-provider-btn.provider-apple::before, +.monaco-workbench.vs .sessions-walkthrough-provider-btn.provider-apple::before { + background-image: url('./apple-light.svg'); +} + +/* ---- Provider button row ---- */ + +.sessions-walkthrough-sign-in-actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + width: min(100%, 400px); + margin-top: 16px; +} + +.sessions-walkthrough-providers-row { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-start; + width: 100%; + flex-wrap: nowrap; +} + +.sessions-walkthrough-providers-row > .sessions-walkthrough-provider-btn { + flex: 0 0 auto; +} + +.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-primary { + flex: 1 1 auto; + min-width: 0; + padding: 10px 12px; + gap: 8px; +} + +.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only { + width: 40px; + height: 40px; + padding: 0; + border-radius: 8px; + justify-content: center; + flex: 0 0 40px; +} + +.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only::before { + width: 18px; + height: 18px; +} + +.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-compact { + min-height: 40px; + padding: 0 6px; + border-radius: 8px; + font-size: 11px; + gap: 0; + justify-content: center; + flex: 0 0 auto; +} + +.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-compact::before { + display: none; +} + +/* ---- Hero layout (icon left, text right) ---- */ + +.sessions-walkthrough-hero { + display: flex; + align-items: stretch; + gap: 24px; +} + +.sessions-walkthrough-hero .sessions-walkthrough-icon { + display: flex; + align-items: center; +} + +.sessions-walkthrough-hero-text { + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; +} + +.sessions-walkthrough-hero-text h2 { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.sessions-walkthrough-hero-text p { + margin: 0; + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; +} + +.sessions-walkthrough-icon .codicon { + font-size: 48px; + color: var(--vscode-descriptionForeground); +} + +.sessions-walkthrough-icon.sessions-walkthrough-icon-large .codicon { + font-size: 112px; +} + +/* Sessions logo for sign-in screen */ +.sessions-walkthrough-logo { + width: 112px; + height: 112px; + background-image: url('./sessions-logo-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + flex-shrink: 0; + align-self: center; +} + +.vs .sessions-walkthrough-logo, +.hc-light .sessions-walkthrough-logo { + background-image: url('./sessions-logo-dark.svg'); +} + +/* ---- Action area (used inside content for sign-in step spinner) ---- */ + +.sessions-walkthrough-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} + +.sessions-walkthrough-spinner .codicon-loading { + animation: sessions-walkthrough-spin 1.5s linear infinite; +} + +@keyframes sessions-walkthrough-spin { + to { + transform: rotate(360deg); + } +} + +.sessions-walkthrough-error { + margin: 0; + font-size: 12px; + color: var(--vscode-errorForeground); +} + +.sessions-walkthrough-disclaimer { + position: absolute; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + width: min(720px, calc(100vw - 64px)); + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 11px; + line-height: 1.4; + text-align: center; +} + +.sessions-walkthrough-disclaimer > a, +.sessions-walkthrough-disclaimer > a:link, +.sessions-walkthrough-disclaimer > a:visited { + color: var(--vscode-textLink-foreground) !important; + -webkit-text-fill-color: var(--vscode-textLink-foreground) !important; + text-decoration: none !important; + outline: none !important; +} + +.sessions-walkthrough-disclaimer > a:hover, +.sessions-walkthrough-disclaimer > a:active { + color: var(--vscode-textLink-activeForeground) !important; + -webkit-text-fill-color: var(--vscode-textLink-activeForeground) !important; + text-decoration: underline !important; +} + +.sessions-walkthrough-disclaimer > a:focus-visible { + outline: none !important; + box-shadow: 0 0 0 1px var(--vscode-focusBorder) !important; + border-radius: 2px !important; +} + +/* Reduced motion */ + +.monaco-reduce-motion .sessions-walkthrough-overlay, +.monaco-reduce-motion .sessions-walkthrough-content, +.monaco-reduce-motion .sessions-walkthrough-provider-btn, +.monaco-reduce-motion .sessions-walkthrough-provider-secondary, +.agent-sessions-workbench.monaco-reduce-motion .sessions-walkthrough-overlay, +.agent-sessions-workbench.monaco-reduce-motion .sessions-walkthrough-content, +.agent-sessions-workbench.monaco-reduce-motion .sessions-walkthrough-provider-btn, +.agent-sessions-workbench.monaco-reduce-motion .sessions-walkthrough-provider-secondary { + transition-duration: 0ms !important; +} + +.monaco-reduce-motion .sessions-walkthrough-progress-bar-fill, +.agent-sessions-workbench.monaco-reduce-motion .sessions-walkthrough-progress-bar-fill { + animation: none !important; +} diff --git a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css deleted file mode 100644 index e5203a11e7f67..0000000000000 --- a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css +++ /dev/null @@ -1,147 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* ---- Welcome Overlay (blocks the sessions window) ---- */ - -.sessions-welcome-overlay { - position: absolute; - inset: 0; - z-index: 200; - display: flex; - align-items: center; - justify-content: center; - background: var(--vscode-editor-background); - opacity: 1; - transition: opacity 200ms ease-out; -} - -.sessions-welcome-overlay.sessions-welcome-overlay-dismissed { - opacity: 0; - pointer-events: none; -} - -/* ---- Card ---- */ - -.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; -} - -/* ---- Header ---- */ - -.sessions-welcome-header h2 { - margin: 0 0 4px 0; - font-size: 18px; - font-weight: 600; - color: var(--vscode-foreground); -} - -.sessions-welcome-subtitle { - margin: 0; - font-size: 13px; - color: var(--vscode-descriptionForeground); -} - -/* ---- Step List ---- */ - -.sessions-welcome-step-list { - 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; -} - -.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)); -} - -/* ---- Step Indicator (number or check icon) ---- */ - -.sessions-welcome-step-indicator { - 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); - 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; - animation: sessions-spin 1.5s linear infinite; -} - -@keyframes sessions-spin { - 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 { - margin: 0; - font-size: 12px; - color: var(--vscode-errorForeground); -} diff --git a/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts new file mode 100644 index 0000000000000..66d726bb919c9 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/sessionsWalkthrough.ts @@ -0,0 +1,423 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsWalkthrough.css'; +import { disposableTimeout } from '../../../../base/common/async.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, append, EventType, addDisposableListener, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.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 { ChatSetupStrategy } from '../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.js'; +import { URI } from '../../../../base/common/uri.js'; + +export type WalkthroughOutcome = 'completed' | 'dismissed'; + +const fadeDuration = 200; +const resetMessageDuration = 2000; +const dismissDuration = 250; +const fallbackChatAgentLinks = { + termsStatementUrl: 'https://aka.ms/github-copilot-terms-statement', + privacyStatementUrl: 'https://aka.ms/github-copilot-privacy-statement', + publicCodeMatchesUrl: 'https://aka.ms/github-copilot-match-public-code', + manageSettingsUrl: 'https://aka.ms/github-copilot-settings' +}; + +/** + * Sign-in onboarding overlay: + * - Sign in via GitHub / Google / Apple + */ +export class SessionsWalkthroughOverlay extends Disposable { + + private readonly overlay: HTMLElement; + private readonly card: HTMLElement; + private readonly contentContainer: HTMLElement; + private readonly footerContainer: HTMLElement; + private readonly disclaimerElement: HTMLElement; + private readonly disclaimerLinks: readonly HTMLAnchorElement[]; + private readonly stepDisposables = this._register(new MutableDisposable()); + private readonly previouslyFocusedElement: HTMLElement | undefined; + private currentFocusableElements: readonly HTMLElement[] = []; + private _resolveOutcome!: (outcome: WalkthroughOutcome) => void; + private _outcomeResolved = false; + + /** Resolves when the user completes or dismisses the walkthrough. */ + readonly outcome: Promise = new Promise(resolve => { this._resolveOutcome = resolve; }); + + constructor( + container: HTMLElement, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @ICommandService private readonly commandService: ICommandService, + @IExtensionService private readonly extensionService: IExtensionService, + @IOpenerService private readonly openerService: IOpenerService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + const activeElement = getActiveElement(); + this.previouslyFocusedElement = isHTMLElement(activeElement) ? activeElement : undefined; + + this.overlay = append(container, $('.sessions-walkthrough-overlay')); + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); + this.overlay.setAttribute('aria-label', localize('walkthrough.aria', "Agents onboarding walkthrough")); + this._register(toDisposable(() => this.overlay.remove())); + this._register(addDisposableListener(this.overlay, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (e.key === 'Tab') { + this._trapFocus(e); + } + })); + this._register(addDisposableListener(this.overlay, EventType.MOUSE_DOWN, e => { + if (e.target === this.overlay) { + e.preventDefault(); + e.stopPropagation(); + } + })); + + this.card = append(this.overlay, $('.sessions-walkthrough-card')); + + // Scrollable content area + this.contentContainer = append(this.card, $('.sessions-walkthrough-content')); + + // Fixed footer + this.footerContainer = append(this.card, $('.sessions-walkthrough-footer')); + const disclaimer = this._createDisclaimer(); + this.disclaimerElement = disclaimer.element; + this.disclaimerLinks = disclaimer.links; + + this._renderSignIn(); + } + + // ------------------------------------------------------------------ + // Sign In + + private _renderSignIn(): void { + const stepDisposables = this.stepDisposables.value = new DisposableStore(); + + this.contentContainer.textContent = ''; + this.footerContainer.textContent = ''; + this.disclaimerElement.classList.toggle('hidden', this.disclaimerLinks.length === 0); + + // Horizontal layout: icon left, text + buttons right + const layout = append(this.contentContainer, $('.sessions-walkthrough-hero')); + + append(layout, $('div.sessions-walkthrough-logo')); + + const right = append(layout, $('.sessions-walkthrough-hero-text')); + const titleEl = append(right, $('h2', undefined, localize('walkthrough.step1.title', "Welcome to Agents"))); + const subtitleEl = append(right, $('p', undefined, localize('walkthrough.step1.subtitle', "Sign in to continue with agent-powered development."))); + + // If already signed in, finish immediately so the app can render. + if (this._isAlreadySetUp()) { + this.complete(); + return; + } + + const signInActions = append(right, $('.sessions-walkthrough-sign-in-actions')); + const providerRow = append(signInActions, $('.sessions-walkthrough-providers-row')); + + const githubBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-primary.provider-github')) as HTMLButtonElement; + append(githubBtn, $('span.sessions-walkthrough-provider-label', undefined, localize('walkthrough.signin.github', "Continue with GitHub"))); + + const googleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-google')) as HTMLButtonElement; + googleBtn.setAttribute('aria-label', localize('walkthrough.signin.google', "Continue with Google")); + googleBtn.title = localize('walkthrough.signin.google', "Continue with Google"); + + const appleBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-icon-only.provider-apple')) as HTMLButtonElement; + appleBtn.setAttribute('aria-label', localize('walkthrough.signin.apple', "Continue with Apple")); + appleBtn.title = localize('walkthrough.signin.apple', "Continue with Apple"); + + const enterpriseProviderName = this.productService.defaultChatAgent?.provider?.enterprise?.name || 'GHE.com'; + const enterpriseBtn = append(providerRow, $('button.sessions-walkthrough-provider-btn.sessions-walkthrough-provider-compact.provider-enterprise')) as HTMLButtonElement; + enterpriseBtn.setAttribute('aria-label', localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName)); + enterpriseBtn.title = localize('walkthrough.signin.enterprise', "Continue with {0}", enterpriseProviderName); + append(enterpriseBtn, $('span.sessions-walkthrough-provider-label', undefined, enterpriseProviderName)); + + // Error feedback below providers + const errorContainer = append(this.footerContainer, $('p.sessions-walkthrough-error')); + errorContainer.style.display = 'none'; + + // Focus the first provider button so keyboard users can interact immediately + disposableTimeout(() => { + if (this.overlay.isConnected && !githubBtn.disabled) { + githubBtn.focus(); + } + }, 0, stepDisposables); + + const providerButtons = [githubBtn, googleBtn, appleBtn, enterpriseBtn]; + this.currentFocusableElements = [...providerButtons, ...this.disclaimerLinks]; + const providerStrategies = [ + ChatSetupStrategy.SetupWithoutEnterpriseProvider, + ChatSetupStrategy.SetupWithGoogleProvider, + ChatSetupStrategy.SetupWithAppleProvider, + ChatSetupStrategy.SetupWithEnterpriseProvider, + ]; + for (let i = 0; i < providerButtons.length; i++) { + const strategy = providerStrategies[i]; + stepDisposables.add(addDisposableListener(providerButtons[i], EventType.CLICK, () => this._runSignIn( + providerButtons, + errorContainer, + strategy, + titleEl, + subtitleEl, + signInActions + ))); + } + } + + private _isAlreadySetUp(): boolean { + const { sentiment, entitlement } = this.chatEntitlementService; + return !!( + sentiment?.installed && + !sentiment?.disabled && + entitlement !== ChatEntitlement.Available && + !(entitlement === ChatEntitlement.Unknown && !this.chatEntitlementService.anonymous) + ); + } + + private async _runSignIn(providerButtons: HTMLButtonElement[], error: HTMLElement, strategy: ChatSetupStrategy, titleEl: HTMLElement, subtitleEl: HTMLElement, signInActions: HTMLElement): Promise { + // Disable all provider buttons + for (const btn of providerButtons) { + btn.disabled = true; + } + this.currentFocusableElements = []; + + error.style.display = 'none'; + + // Fade the content + this.disclaimerElement.classList.add('hidden'); + this.contentContainer.classList.add('sessions-walkthrough-fade-out'); + await this._wait(fadeDuration); + if (this._shouldAbortUpdate(titleEl, subtitleEl, signInActions)) { + return; + } + + // Swap title and subtitle in-place + titleEl.textContent = localize('walkthrough.settingUp', "Signing in\u2026"); + subtitleEl.textContent = localize('walkthrough.poweredBy', "Complete authorization in your browser."); + + // Replace sign-in actions with progress bar + const heroText = signInActions.parentElement; + if (!heroText) { + return; + } + signInActions.remove(); + append(heroText, $('.sessions-walkthrough-progress-bar', undefined, $('.sessions-walkthrough-progress-bar-fill'))); + + // Fade back in + this.contentContainer.classList.remove('sessions-walkthrough-fade-out'); + + try { + const success = await this.commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, { + setupStrategy: strategy + }); + if (this._shouldAbortUpdate(titleEl, subtitleEl)) { + return; + } + + if (success) { + // Update title and subtitle for the finishing phase + titleEl.textContent = localize('walkthrough.signingIn', "Finishing setup\u2026"); + subtitleEl.textContent = localize('walkthrough.finishingSubtitle', "Getting everything ready for you."); + + this.logService.info('[sessions walkthrough] Restarting extension host after setup'); + const stopped = await this.extensionService.stopExtensionHosts( + localize('walkthrough.restart', "Completing Agents setup") + ); + if (this._shouldAbortUpdate(titleEl, subtitleEl)) { + return; + } + if (stopped) { + await this.extensionService.startExtensionHosts(); + if (this._shouldAbortUpdate(titleEl, subtitleEl)) { + return; + } + } + + this.complete(); + } else { + // Show cancellation feedback, then reset to sign-in + error.textContent = localize('walkthrough.canceledError', "Sign-in was canceled. Please try again."); + error.style.display = ''; + await this._wait(resetMessageDuration); + if (this._shouldAbortUpdate(error)) { + return; + } + error.style.display = 'none'; + + this.contentContainer.classList.add('sessions-walkthrough-fade-out'); + await this._wait(fadeDuration); + if (!this.overlay.isConnected) { + return; + } + this.contentContainer.classList.remove('sessions-walkthrough-fade-out'); + this._renderSignIn(); + } + } catch (err) { + this.logService.error('[sessions walkthrough] Sign-in failed:', err); + + // Show error feedback, then reset to sign-in + error.textContent = localize('walkthrough.signInError', "Something went wrong. Please try again."); + error.style.display = ''; + await this._wait(resetMessageDuration); + if (this._shouldAbortUpdate(error)) { + return; + } + error.style.display = 'none'; + + this.contentContainer.classList.add('sessions-walkthrough-fade-out'); + await this._wait(fadeDuration); + if (!this.overlay.isConnected) { + return; + } + this.contentContainer.classList.remove('sessions-walkthrough-fade-out'); + this._renderSignIn(); + } + } + + // ------------------------------------------------------------------ + // Lifecycle + + complete(): void { + this._finish('completed'); + } + + private _finish(outcome: WalkthroughOutcome): void { + this.overlay.classList.add('sessions-walkthrough-dismissed'); + this._register(disposableTimeout(() => this.dispose(), dismissDuration)); + if (!this._outcomeResolved) { + this._outcomeResolved = true; + this._resolveOutcome(outcome); + } + } + + dismiss(): void { + this._finish('dismissed'); + } + + override dispose(): void { + // If the overlay is disposed without an explicit finish (e.g. cleared by + // the owner's DisposableStore), treat it as a dismissal so that `outcome` + // always resolves and callers are never left waiting on a pending promise. + if (!this._outcomeResolved) { + this._outcomeResolved = true; + this._resolveOutcome('dismissed'); + } + super.dispose(); + if (this.previouslyFocusedElement?.isConnected) { + this.previouslyFocusedElement.focus(); + } + } + + private _trapFocus(event: KeyboardEvent): void { + const focusableElements = this._getFocusableElements(); + if (!focusableElements.length) { + return; + } + + const activeElement = getActiveElement(); + const fallbackElement = event.shiftKey ? focusableElements[focusableElements.length - 1] : focusableElements[0]; + if (!isHTMLElement(activeElement)) { + event.preventDefault(); + fallbackElement?.focus(); + return; + } + + const focusedIndex = focusableElements.indexOf(activeElement); + if (focusedIndex === -1) { + event.preventDefault(); + fallbackElement?.focus(); + return; + } + + if (!event.shiftKey && focusedIndex === focusableElements.length - 1) { + event.preventDefault(); + focusableElements[0].focus(); + } else if (event.shiftKey && focusedIndex === 0) { + event.preventDefault(); + focusableElements[focusableElements.length - 1]?.focus(); + } + } + + private _getFocusableElements(): HTMLElement[] { + return this.currentFocusableElements.filter(element => element.isConnected); + } + + private _wait(duration: number): Promise { + return new Promise(resolve => { + let didResolve = false; + const timeoutDisposables = this.stepDisposables.value?.add(new DisposableStore()) ?? this._register(new DisposableStore()); + const complete = () => { + if (didResolve) { + return; + } + + didResolve = true; + timeoutDisposables.dispose(); + resolve(); + }; + + timeoutDisposables.add(disposableTimeout(complete, duration)); + timeoutDisposables.add(toDisposable(complete)); + }); + } + + private _shouldAbortUpdate(...elements: HTMLElement[]): boolean { + return !this.overlay.isConnected || elements.some(element => !element.isConnected); + } + + private _createDisclaimer(): { element: HTMLElement; links: readonly HTMLAnchorElement[] } { + const defaultChatAgent = this.productService.defaultChatAgent; + const disclaimer = append(this.overlay, $('p.sessions-walkthrough-disclaimer.hidden')); + const termsStatementUrl = defaultChatAgent?.termsStatementUrl || fallbackChatAgentLinks.termsStatementUrl; + const privacyStatementUrl = defaultChatAgent?.privacyStatementUrl || fallbackChatAgentLinks.privacyStatementUrl; + const publicCodeMatchesUrl = defaultChatAgent?.publicCodeMatchesUrl || fallbackChatAgentLinks.publicCodeMatchesUrl; + const manageSettingsUrl = defaultChatAgent?.manageSettingsUrl || fallbackChatAgentLinks.manageSettingsUrl; + + const termsLink = this._appendDisclaimerLink(termsStatementUrl, localize('walkthrough.disclaimer.terms', "Terms")); + const privacyLink = this._appendDisclaimerLink(privacyStatementUrl, localize('walkthrough.disclaimer.privacy', "Privacy Statement")); + const publicCodeLink = this._appendDisclaimerLink(publicCodeMatchesUrl, localize('walkthrough.disclaimer.publicCode', "public code")); + const settingsLink = this._appendDisclaimerLink(manageSettingsUrl, localize('walkthrough.disclaimer.settings', "settings")); + + append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.prefix', "By continuing, you agree to GitHub's "))); + disclaimer.appendChild(termsLink); + append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.middle', " and "))); + disclaimer.appendChild(privacyLink); + append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.suffix', ". GitHub Copilot may show "))); + disclaimer.appendChild(publicCodeLink); + append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.final', " suggestions and use your data to improve the product. You can change these "))); + disclaimer.appendChild(settingsLink); + append(disclaimer, document.createTextNode(localize('walkthrough.disclaimer.end', " anytime."))); + + return { + element: disclaimer, + links: [termsLink, privacyLink, publicCodeLink, settingsLink] + }; + } + + private _appendDisclaimerLink(href: string, label: string): HTMLAnchorElement { + const link = $('a', { href }, label) as HTMLAnchorElement; + this._register(addDisposableListener(link, EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + if (href) { + void this.openerService.open(URI.parse(href), { fromUserGesture: true }); + } + })); + return link; + } +} 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..fbcde92887f71 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -3,32 +3,43 @@ * 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 { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize2 } from '../../../../nls.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 { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.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'; +import { SessionsWalkthroughOverlay, WalkthroughOutcome } from './sessionsWalkthrough.js'; const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; -// Register the service -registerSingleton(ISessionsWelcomeService, SessionsWelcomeService, InstantiationType.Eager); +function needsChatSetup(chatEntitlementService: Pick, includeUnknown: boolean = true): boolean { + const { sentiment, entitlement } = chatEntitlementService; + return ( + !sentiment?.installed || + sentiment?.disabled || + entitlement === ChatEntitlement.Available || + ( + includeUnknown && + entitlement === ChatEntitlement.Unknown && + !chatEntitlementService.anonymous + ) + ); +} -class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { +function shouldPersistWelcomeCompletion(outcome: WalkthroughOutcome, chatEntitlementService: Pick): boolean { + return outcome === 'completed' || !needsChatSetup(chatEntitlementService); +} +export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -36,120 +47,174 @@ 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, + @ILogService private readonly logService: ILogService, ) { 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(); + this.showWalkthrough(); } else { - // Returning user: only show if Copilot Chat is not installed - this.showOverlayIfNeededAfterInit(); + this.showWalkthroughIfNeeded(); } } - 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 showWalkthroughIfNeeded(): void { + if (this._needsChatSetup()) { + this.showWalkthrough(); + } 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 showWalkthroughIfNeeded} 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.showOverlay(); + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + const needsSetup = this._needsChatSetup(false); + if (setupComplete && needsSetup) { + this.showWalkthrough(); } - wasComplete = isComplete; + setupComplete = !needsSetup; }); } - private showOverlay(): void { + private _needsChatSetup(includeUnknown: boolean = true): boolean { + return needsChatSetup(this.chatEntitlementService, includeUnknown); + } + + private showWalkthrough(): void { if (this.overlayRef.value) { - return; // overlay already shown + return; } + this.watcherRef.clear(); this.overlayRef.value = new DisposableStore(); + let welcomeCompletionStored = false; + + // 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, + const walkthrough = this.overlayRef.value.add(this.instantiationService.createInstance( + SessionsWalkthroughOverlay, 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 chat setup completes (observables flip), persist completion and + // finish the walkthrough so the app can render immediately. + this.overlayRef.value.add(autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + if (!welcomeCompletionStored && !this._needsChatSetup()) { + welcomeCompletionStored = true; + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + walkthrough.complete(); + } })); + + // Handle the walkthrough outcome + walkthrough.outcome.then(outcome => { + this.logService.info(`[sessions welcome] Walkthrough finished with outcome: ${outcome}`); + if (!welcomeCompletionStored && shouldPersistWelcomeCompletion(outcome, this.chatEntitlementService)) { + welcomeCompletionStored = true; + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + 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({ id: 'workbench.action.resetSessionsWelcome', - title: localize2('resetSessionsWelcome', "Reset Sessions Welcome"), + title: localize2('resetSessionsWelcome', "Reset Agents Welcome"), category: Categories.Developer, f1: true, }); } run(accessor: ServicesAccessor): void { const storageService = accessor.get(IStorageService); + const instantiationService = accessor.get(IInstantiationService); + const layoutService = accessor.get(IWorkbenchLayoutService); + const chatEntitlementService = accessor.get(IChatEntitlementService); + const contextKeyService = accessor.get(IContextKeyService); + const logService = accessor.get(ILogService); + + // Clear completion marker storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + + // Immediately show the walkthrough overlay + const store = new DisposableStore(); + const welcomeVisibleKey = SessionsWelcomeVisibleContext.bindTo(contextKeyService); + welcomeVisibleKey.set(true); + store.add(toDisposable(() => welcomeVisibleKey.reset())); + + const walkthrough = store.add(instantiationService.createInstance( + SessionsWalkthroughOverlay, + layoutService.mainContainer, + )); + + store.add(autorun(reader => { + chatEntitlementService.sentimentObs.read(reader); + chatEntitlementService.entitlementObs.read(reader); + + if (!needsChatSetup(chatEntitlementService)) { + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + walkthrough.complete(); + store.dispose(); + } + })); + + walkthrough.outcome + .then(outcome => { + logService.info(`[sessions welcome] Developer reset walkthrough finished with outcome: ${outcome}`); + if (shouldPersistWelcomeCompletion(outcome, chatEntitlementService)) { + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + }) + .finally(() => { + store.dispose(); + }); } }); 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..5d20bb34abfb7 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -0,0 +1,516 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatSetupStrategy } from '../../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.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'; +import { SessionsWalkthroughOverlay, WalkthroughOutcome } from '../../browser/sessionsWalkthrough.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', { completed: true, 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 { } +} + +class TestWalkthroughOverlay extends Disposable { + + private _resolveOutcome!: (outcome: WalkthroughOutcome) => void; + readonly outcome: Promise = new Promise(resolve => { + this._resolveOutcome = resolve; + }); + + resolve(outcome: WalkthroughOutcome): void { + this._resolveOutcome(outcome); + } + + complete(): void { + this.resolve('completed'); + } +} + +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; + } + + async function flushMicrotasks(): Promise { + await Promise.resolve(); + } + + test('first launch shows overlay', () => { + // First launch with no entitlement — should show overlay + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ completed: false, 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({ completed: true, 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({ completed: true, 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({ completed: true, 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({ completed: true, 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({ completed: true, installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate extension being disabled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ completed: true, installed: true, disabled: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is disabled'); + }); + + test('setup completion dismisses overlay and persists welcome completion', async () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ completed: false, 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 setup completion; the walkthrough remains visible until it resolves + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + mockEntitlementService.sentimentObs.set({ completed: true, installed: true } as IChatSentiment, tx); + }); + await flushMicrotasks(); + + const storageService = instantiationService.get(IStorageService); + assert.strictEqual(storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false), true); + assert.strictEqual(isOverlayVisible(), false, 'should dismiss once setup completes'); + }); + + test('walkthrough cannot be dismissed by Escape or backdrop click', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + instantiationService.stub(ICommandService, { + executeCommand: () => Promise.resolve(false) + } as unknown as ICommandService); + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + instantiationService.stub(IDefaultAccountService, { + getDefaultAccount: () => Promise.resolve(undefined) + } as unknown as IDefaultAccountService); + instantiationService.stub(ILogService, new NullLogService()); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const overlayElement = container.querySelector('.sessions-walkthrough-overlay'); + assert.ok(overlayElement); + + overlayElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.strictEqual(overlayElement.isConnected, true, 'Escape should not dismiss the walkthrough'); + assert.strictEqual(overlayElement.classList.contains('sessions-walkthrough-dismissed'), false); + + overlayElement.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + assert.strictEqual(overlayElement.isConnected, true, 'Backdrop click should not dismiss the walkthrough'); + assert.strictEqual(overlayElement.classList.contains('sessions-walkthrough-dismissed'), false); + + overlay.dispose(); + } finally { + container.remove(); + } + }); + + test('walkthrough preserves provider-specific sign-in strategies', async () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + let commandArgs: unknown[] | undefined; + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + instantiationService.stub(IDefaultAccountService, { + getDefaultAccount: () => Promise.resolve(undefined) + } as unknown as IDefaultAccountService); + instantiationService.stub(ILogService, new NullLogService()); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const assertButtonStrategy = async (selector: string, expectedStrategy: ChatSetupStrategy) => { + commandArgs = undefined; + let resolveExecuteCommandCalled!: () => void; + const executeCommandCalled = new Promise(resolve => { + resolveExecuteCommandCalled = resolve; + }); + instantiationService.stub(ICommandService, { + executeCommand: (...args: unknown[]) => { + commandArgs = args; + resolveExecuteCommandCalled(); + return Promise.resolve(false); + } + } as unknown as ICommandService); + + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const githubButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-github'); + const googleButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-google'); + const appleButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-apple'); + const enterpriseButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-enterprise'); + assert.ok(githubButton); + assert.ok(googleButton); + assert.ok(appleButton); + assert.ok(enterpriseButton); + + const button = container.querySelector(selector); + assert.ok(button); + button.click(); + await executeCommandCalled; + + assert.ok(commandArgs); + assert.deepStrictEqual(commandArgs?.[1], { + setupStrategy: expectedStrategy + }); + + overlay.dispose(); + container.textContent = ''; + }; + + await assertButtonStrategy('.sessions-walkthrough-provider-btn.provider-apple', ChatSetupStrategy.SetupWithAppleProvider); + await assertButtonStrategy('.sessions-walkthrough-provider-btn.provider-google', ChatSetupStrategy.SetupWithGoogleProvider); + await assertButtonStrategy('.sessions-walkthrough-provider-btn.provider-enterprise', ChatSetupStrategy.SetupWithEnterpriseProvider); + } finally { + container.remove(); + } + }); + + test('enterprise sign-in option is removed after setup begins', async () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + let resolveExecuteCommand!: () => void; + const executeCommandStarted = new Promise(resolve => { + resolveExecuteCommand = resolve; + }); + + instantiationService.stub(ICommandService, { + executeCommand: () => { + resolveExecuteCommand(); + return new Promise(() => { }); + } + } as unknown as ICommandService); + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + instantiationService.stub(ILogService, new NullLogService()); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const enterpriseButton = container.querySelector('.sessions-walkthrough-provider-btn.provider-enterprise'); + assert.ok(enterpriseButton); + + enterpriseButton.click(); + await executeCommandStarted; + await new Promise(resolve => setTimeout(resolve, 250)); + + assert.strictEqual(container.querySelector('.sessions-walkthrough-provider-btn.provider-enterprise'), null); + assert.strictEqual(container.querySelector('.sessions-walkthrough-provider-btn'), null); + + overlay.dispose(); + } finally { + container.remove(); + } + }); + + test('walkthrough shows disclaimer links on the initial sign-in screen', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + instantiationService.stub(ICommandService, { + executeCommand: () => Promise.resolve(false) + } as unknown as ICommandService); + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + instantiationService.stub(IDefaultAccountService, { + getDefaultAccount: () => Promise.resolve(undefined) + } as unknown as IDefaultAccountService); + instantiationService.stub(ILogService, new NullLogService()); + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { + ...productService.defaultChatAgent, + chatExtensionId: 'test.chat', + termsStatementUrl: 'https://example.com/terms', + privacyStatementUrl: 'https://example.com/privacy', + publicCodeMatchesUrl: 'https://example.com/public-code', + manageSettingsUrl: 'https://example.com/settings' + } + } as IProductService); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const disclaimer = container.querySelector('.sessions-walkthrough-disclaimer'); + assert.ok(disclaimer); + assert.strictEqual(disclaimer.classList.contains('hidden'), false); + + const links = Array.from(disclaimer.querySelectorAll('a')); + assert.deepStrictEqual(links.map(link => link.textContent), ['Terms', 'Privacy Statement', 'public code', 'settings']); + + overlay.dispose(); + } finally { + container.remove(); + } + }); + + test('walkthrough falls back to default disclaimer links when product links are missing', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + instantiationService.stub(ICommandService, { + executeCommand: () => Promise.resolve(false) + } as unknown as ICommandService); + instantiationService.stub(IExtensionService, { + stopExtensionHosts: () => Promise.resolve(false), + startExtensionHosts: () => Promise.resolve() + } as unknown as IExtensionService); + instantiationService.stub(ILogService, new NullLogService()); + + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { + ...productService.defaultChatAgent, + chatExtensionId: 'test.chat', + termsStatementUrl: '', + privacyStatementUrl: '', + publicCodeMatchesUrl: '', + manageSettingsUrl: '' + } + } as IProductService); + + const container = document.createElement('div'); + document.body.appendChild(container); + + try { + const overlay = disposables.add(instantiationService.createInstance(SessionsWalkthroughOverlay, container)); + const disclaimer = container.querySelector('.sessions-walkthrough-disclaimer'); + assert.ok(disclaimer); + assert.strictEqual(disclaimer.classList.contains('hidden'), false); + assert.deepStrictEqual( + Array.from(disclaimer.querySelectorAll('a')).map(link => link.getAttribute('href')), + [ + 'https://aka.ms/github-copilot-terms-statement', + 'https://aka.ms/github-copilot-privacy-statement', + 'https://aka.ms/github-copilot-match-public-code', + 'https://aka.ms/github-copilot-settings' + ] + ); + + overlay.dispose(); + } finally { + container.remove(); + } + }); + + test('dismissing walkthrough does not mark welcome complete', async () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const walkthrough = new TestWalkthroughOverlay(); + instantiationService.stubInstance(SessionsWalkthroughOverlay, walkthrough as unknown as SessionsWalkthroughOverlay); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + + walkthrough.resolve('dismissed'); + await flushMicrotasks(); + + const storageService = instantiationService.get(IStorageService); + assert.strictEqual(storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false), false); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('completing walkthrough marks welcome complete', async () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const walkthrough = new TestWalkthroughOverlay(); + instantiationService.stubInstance(SessionsWalkthroughOverlay, walkthrough as unknown as SessionsWalkthroughOverlay); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + + walkthrough.resolve('completed'); + await flushMicrotasks(); + + const storageService = instantiationService.get(IStorageService); + assert.strictEqual(storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false), true); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('returning user: entitlement going to Available DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ completed: true, 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..01fa1c6ae85ba --- /dev/null +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { Queue } from '../../../../base/common/async.js'; +import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { ISession } 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); + activeSession?.workspace.read(reader); + this.queue.queue(() => this.updateWorkspaceFoldersForSession(activeSession)); + })); + } + + private async updateWorkspaceFoldersForSession(session: ISession | 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: ISession | 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; + } + return { + uri: repository, + name: workspace?.label, + }; + } + + return undefined; + } + + private async manageTrustWorkspaceForSession(session: ISession | undefined): Promise { + const workspace = session?.workspace.get(); + if (!workspace?.requiresWorkspaceTrust) { + return; + } + + 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..4f92903ef7b8f --- /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 agent 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/electron-browser/sessions.ts b/src/vs/sessions/electron-browser/sessions.ts index ce01931aa2b68..cc7f8d40c6d6d 100644 --- a/src/vs/sessions/electron-browser/sessions.ts +++ b/src/vs/sessions/electron-browser/sessions.ts @@ -25,9 +25,48 @@ function showSplash(configuration: INativeWindowConfiguration) { performance.mark('code/willShowPartsSplash'); - const baseTheme = 'vs-dark'; - const shellBackground = '#191A1B'; - const shellForeground = '#CCCCCC'; + let data = configuration.partsSplash; + if (data) { + if (configuration.autoDetectHighContrast && configuration.colorScheme.highContrast) { + if ((configuration.colorScheme.dark && data.baseTheme !== 'hc-black') || (!configuration.colorScheme.dark && data.baseTheme !== 'hc-light')) { + data = undefined; // high contrast mode has been turned by the OS -> ignore stored colors and layouts + } + } else if (configuration.autoDetectColorScheme) { + if ((configuration.colorScheme.dark && data.baseTheme !== 'vs-dark') || (!configuration.colorScheme.dark && data.baseTheme !== 'vs')) { + data = undefined; // OS color scheme is tracked and has changed + } + } + } + + // minimal color configuration (works with or without persisted data) + let baseTheme = 'vs-dark'; + let shellBackground = '#1E1E1E'; + let shellForeground = '#CCCCCC'; + if (data) { + baseTheme = data.baseTheme; + shellBackground = data.colorInfo.editorBackground ?? data.colorInfo.background; + shellForeground = data.colorInfo.foreground ?? shellForeground; + } else if (configuration.autoDetectHighContrast && configuration.colorScheme.highContrast) { + if (configuration.colorScheme.dark) { + baseTheme = 'hc-black'; + shellBackground = '#000000'; + shellForeground = '#FFFFFF'; + } else { + baseTheme = 'hc-light'; + shellBackground = '#FFFFFF'; + shellForeground = '#000000'; + } + } else if (configuration.autoDetectColorScheme) { + if (configuration.colorScheme.dark) { + baseTheme = 'vs-dark'; + shellBackground = '#1E1E1E'; + shellForeground = '#CCCCCC'; + } else { + baseTheme = 'vs'; + shellBackground = '#FFFFFF'; + shellForeground = '#000000'; + } + } // Apply base colors const style = document.createElement('style'); @@ -36,13 +75,13 @@ style.textContent = `body { background-color: ${shellBackground}; color: ${shellForeground}; margin: 0; padding: 0; }`; // Set zoom level from splash data if available - if (typeof configuration.partsSplash?.zoomLevel === 'number' && typeof preloadGlobals?.webFrame?.setZoomLevel === 'function') { - preloadGlobals.webFrame.setZoomLevel(configuration.partsSplash.zoomLevel); + if (typeof data?.zoomLevel === 'number' && typeof preloadGlobals?.webFrame?.setZoomLevel === 'function') { + preloadGlobals.webFrame.setZoomLevel(data.zoomLevel); } const splash = document.createElement('div'); splash.id = 'monaco-parts-splash'; - splash.className = baseTheme; + splash.className = baseTheme ?? 'vs-dark'; window.document.body.appendChild(splash); 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..8e96974a0abc6 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,32 @@ 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/layout/browser/layout.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..bd771a1ddecd2 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'; @@ -90,6 +89,7 @@ import '../workbench/services/extensions/electron-browser/nativeExtensionService import '../platform/userDataProfile/electron-browser/userDataProfileStorageService.js'; import '../workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; +import '../platform/sandbox/electron-browser/sandboxHelperService.js'; import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; import '../workbench/services/browserView/electron-browser/playwrightWorkbenchService.js'; import '../workbench/services/process/electron-browser/processService.js'; @@ -178,7 +178,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 +197,15 @@ 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 '../platform/agentHost/electron-browser/sshRemoteAgentHostService.js'; +import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; + +// Local Agent Host +import './contrib/localAgentHost/browser/localAgentHost.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..be6b01c6a157d --- /dev/null +++ b/src/vs/sessions/sessions.web.main.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * 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 '../platform/sandbox/browser/sandboxHelperService.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/act-on-feedback/SKILL.md b/src/vs/sessions/skills/act-on-feedback/SKILL.md new file mode 100644 index 0000000000000..a321a21049029 --- /dev/null +++ b/src/vs/sessions/skills/act-on-feedback/SKILL.md @@ -0,0 +1,14 @@ +--- +name: act-on-feedback +description: Act on user feedback attached to the current session. Use when the user submits feedback on the session's changes via the Submit Feedback button. +--- + + +# Act on Feedback + +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/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/create-draft-pr/SKILL.md b/src/vs/sessions/skills/create-draft-pr/SKILL.md new file mode 100644 index 0000000000000..22f1dbbee6673 --- /dev/null +++ b/src/vs/sessions/skills/create-draft-pr/SKILL.md @@ -0,0 +1,16 @@ +--- +name: create-draft-pr +description: Create a draft pull request for the current session. Use when the user wants to open a draft PR with the session's changes. +--- + + +# Create Draft Pull Request + +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/skills/create-pr/SKILL.md b/src/vs/sessions/skills/create-pr/SKILL.md new file mode 100644 index 0000000000000..027f8cfb63d67 --- /dev/null +++ b/src/vs/sessions/skills/create-pr/SKILL.md @@ -0,0 +1,16 @@ +--- +name: create-pr +description: Create a pull request for the current session. Use when the user wants to open a PR with the session's changes. +--- + + +# Create Pull Request + +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/skills/generate-run-commands/SKILL.md b/src/vs/sessions/skills/generate-run-commands/SKILL.md new file mode 100644 index 0000000000000..d43dcda400b98 --- /dev/null +++ b/src/vs/sessions/skills/generate-run-commands/SKILL.md @@ -0,0 +1,53 @@ +--- +name: generate-run-commands +description: Generate or modify run commands for the current session. Use when the user wants to set up or update run commands that appear in the session's Run button. +--- + + +# Generate Run Commands + +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/skills/merge/SKILL.md b/src/vs/sessions/skills/merge/SKILL.md new file mode 100644 index 0000000000000..d3661e85902bb --- /dev/null +++ b/src/vs/sessions/skills/merge/SKILL.md @@ -0,0 +1,72 @@ +--- +name: merge +description: Merge changes from the topic branch to the merge base branch. Use when the user wants to merge their session's work back to the base branch. +--- + + +# Merge Changes + +Merge the topic branch (checked out in the current worktree) into the merge base branch (checked out in the main worktree). The context block appended to the prompt contains the source branch, target branch, and main worktree path. + +## Guidelines + +- **Never force-push** (`--force`, `--force-with-lease`) without explicit user approval. +- **Never skip pre-push hooks** (do not use `--no-verify`). +- **Never rewrite or drop commits** without asking the user. +- When in doubt about conflict resolution — ask the user. + +## Workflow + +### 1. Commit uncommitted changes in the current worktree + +Check for uncommitted changes in the current worktree: +``` +git status --porcelain +``` +If there are uncommitted changes, use the `/commit` skill to commit them before continuing. + +### 2. Merge the topic branch into the base branch + +Use `git -C ` to run commands against the main worktree without leaving the current worktree. + +``` +git -C merge +``` + +### 3. Handle merge conflicts + +If the merge reports conflicts: + +3.1. List conflicted files: +``` +git -C diff --name-only --diff-filter=U +``` + +3.2. For each conflicted file, read the file content, resolve the conflict by preserving the intent of both sides, and stage the resolved file: +``` +git -C add +``` + +3.3. When in doubt on how to resolve a merge conflict, ask the user for guidance. If the user wants to abort, run: +``` +git -C merge --abort +``` + +3.4. Once all conflicts are resolved and staged, commit the merge: +``` +git -C commit --no-edit +``` + +## Validation + +After the merge completes, verify the result: + +1. Confirm the main worktree is clean: +``` +git -C status --porcelain +``` + +2. Confirm the topic branch is an ancestor of the base branch (i.e. all commits are merged): +``` +git -C merge-base --is-ancestor HEAD +``` diff --git a/src/vs/sessions/skills/sync-upstream/SKILL.md b/src/vs/sessions/skills/sync-upstream/SKILL.md new file mode 100644 index 0000000000000..ce3f3224cbd50 --- /dev/null +++ b/src/vs/sessions/skills/sync-upstream/SKILL.md @@ -0,0 +1,31 @@ +--- +name: sync-upstream +description: Update a stale session branch by rebasing onto the latest origin. Use when the upstream has moved significantly and the session needs to catch up, resolving conflicts by preserving upstream changes and adapting session work to fit. +--- + + +# Update Branch + +Rebase the current session branch onto the latest upstream so the work stays grounded in origin. + +## Workflow + +1. If there are uncommitted changes, use the `/commit` skill to commit them first. +2. Fetch the latest upstream and rebase onto it: + ``` + git fetch origin + git rebase origin/main + ``` + Use the appropriate base branch if it is not `main`. + +## Conflict Resolution + +When conflicts arise, **upstream always wins**: + +- **Never alter upstream logic, APIs, or patterns** to accommodate session changes. +- **Adapt session work** to fit the new upstream — rename, restructure, or rewrite as needed while preserving the session's goals. +- After resolving each conflict, `git add` the files and `git rebase --continue`. + +## Validation + +After the rebase completes, verify the result still compiles and meets the session's objectives. If session changes no longer make sense against the updated upstream, explain what changed and propose a revised approach. diff --git a/src/vs/sessions/skills/sync/SKILL.md b/src/vs/sessions/skills/sync/SKILL.md new file mode 100644 index 0000000000000..8f9cc2a11c5a4 --- /dev/null +++ b/src/vs/sessions/skills/sync/SKILL.md @@ -0,0 +1,73 @@ +--- +name: sync +description: Sync the current session branch with its upstream branch, or publish the current session branch to a remote. Use when the user asks to sync a branch, pull latest changes, rebase onto upstream, push current branch, publish branch, or set upstream. +--- + + +# Sync Changes + +Sync the current session branch with its upstream branch, or publish the current session branch to a remote. Use when the user asks to sync a branch, pull latest changes, rebase onto upstream, push current branch, publish branch, or set upstream. + +## Guidelines + +- **Never force-push** (`--force`, `--force-with-lease`) without explicit user approval. +- **Never skip pre-push hooks** (do not use `--no-verify`). +- **Never rewrite or drop commits** during rebase without asking the user. +- When in doubt about conflict resolution — ask the user. + +## Workflow + +1. Check for uncommitted changes first. If there are uncommitted changes, use the `/commit` skill to commit them before continuing. +2. Check whether the current session branch has an upstream branch. +3. If the current session branch has an upstream branch: + 3.1. Fetch the upstream remote first so tracking refs are up to date. + ``` + git fetch + ``` + 3.2. Check ahead/behind counts. If the branch is already in sync (0 ahead, 0 behind), stop and report that no sync is needed. + ``` + git rev-list --left-right --count HEAD...@{u} + ``` + 3.3. If behind, rebase onto the upstream tracking branch. + ``` + git rebase @{u} + ``` + 3.4. If there are merge conflicts, resolve them by preserving the intent of both sides. Stage the resolved files and continue the rebase. + ``` + git add + git rebase --continue + ``` + If conflict resolution is unclear, ask the user how to proceed. If the user wants to stop the rebase, abort it: + ``` + git rebase --abort + ``` + 3.5. If the branch has local commits (ahead > 0), push them to the remote after a successful rebase. + ``` + git push + ``` + If the push is rejected because the rebase rewrote history, explain the situation to the user and ask for approval before force-pushing. +4. If the current session branch does not have an upstream branch: + 4.1. Determine the remote to publish to. + - If there is only one remote, use it. + - If there are multiple remotes, use the #tool:vscode/askQuestions tool to ask which remote to use. + 4.2. Publish the current branch and set upstream in one step. + ``` + git push -u HEAD + ``` + +## Validation + +After the workflow completes, validate the result with explicit checks: + +1. Verify the working tree is clean: + ``` + git status --porcelain + ``` +2. Verify sync state (ahead/behind counts are both 0): + ``` + git rev-list --left-right --count HEAD...@{u} + ``` +3. If the branch was newly published, verify the upstream branch is configured: + ``` + git rev-parse --abbrev-ref --symbolic-full-name @{u} + ``` diff --git a/src/vs/sessions/skills/update-pr/SKILL.md b/src/vs/sessions/skills/update-pr/SKILL.md new file mode 100644 index 0000000000000..394d906d3ead0 --- /dev/null +++ b/src/vs/sessions/skills/update-pr/SKILL.md @@ -0,0 +1,16 @@ +--- +name: update-pr +description: Update the pull request for the current session. Use when the user wants to push new changes to an existing PR. +--- + + +# Update Pull Request + +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/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: