diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md new file mode 100644 index 0000000000000..f967f6d0c1d30 --- /dev/null +++ b/.agents/skills/launch/SKILL.md @@ -0,0 +1,350 @@ +--- +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 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/skills/launch b/.claude/skills/launch new file mode 120000 index 0000000000000..b41e2b420ad03 --- /dev/null +++ b/.claude/skills/launch @@ -0,0 +1 @@ +../../.agents/skills/launch \ 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-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..eaf90f0dd1afd 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 @@ -65,6 +66,7 @@ src/vs/code/** @bpasero @deepak1556 src/vs/workbench/services/activity/** @bpasero src/vs/workbench/services/authentication/** @TylerLeonhardt src/vs/workbench/services/auxiliaryWindow/** @bpasero +src/vs/workbench/services/browserView/** @kycutler @jruales src/vs/workbench/services/contextmenu/** @bpasero src/vs/workbench/services/dialogs/** @alexr00 @bpasero src/vs/workbench/services/editor/** @bpasero @@ -97,6 +99,7 @@ src/vs/workbench/electron-browser/** @bpasero # Workbench Contributions src/vs/workbench/contrib/authentication/** @TylerLeonhardt +src/vs/workbench/contrib/browserView/** @kycutler @jruales src/vs/workbench/contrib/files/** @bpasero src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens src/vs/workbench/contrib/localization/** @TylerLeonhardt diff --git a/.github/agents/data.md b/.github/agents/data.md index 605bd276ef9a3..37f83c638cb79 100644 --- a/.github/agents/data.md +++ b/.github/agents/data.md @@ -42,3 +42,7 @@ Your response should include: - Interpretation and analysis of the results - References to specific documentation files when applicable - Additional context or insights from the telemetry data + +# Troubleshooting + +If the connection to the Kusto cluster is timing out consistently, stop and ask the user to check whether they are connected to Azure VPN. diff --git a/.github/agents/sessions.md b/.github/agents/sessions.md new file mode 100644 index 0000000000000..1bd1d3986c399 --- /dev/null +++ b/.github/agents/sessions.md @@ -0,0 +1,15 @@ +--- +name: Sessions Window Developer +description: Specialist in developing the Agent Sessions Window +--- + +# Role and Objective + +You are a developer working on the 'sessions window'. Your goal is to make changes to the sessions window (`src/vs/sessions`), minimally editing outside of that directory. + +# Instructions + +1. **Always read the `sessions` skill first.** This is your primary source of truth for the sessions architecture. + - Invoke `skill: "sessions"`. +2. Focus your work on `src/vs/sessions/`. +3. Avoid making changes to core VS Code files (`src/vs/workbench/`, `src/vs/platform/`, etc.) unless absolutely necessary for the sessions window functionality. diff --git a/.github/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..8d56465c45a83 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. 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/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/skills/accessibility/SKILL.md b/.github/skills/accessibility/SKILL.md index 1d141f0c09275..591e85ff8123c 100644 --- a/.github/skills/accessibility/SKILL.md +++ b/.github/skills/accessibility/SKILL.md @@ -1,8 +1,22 @@ --- name: accessibility -description: Accessibility guidelines for VS Code features — covers accessibility help dialogs, accessible views, verbosity settings, accessibility signals, ARIA alerts/status announcements, keyboard navigation, and ARIA labels/roles. Applies to both new interactive UI surfaces and updates to existing features. Use when creating new UI or updating existing UI features. +description: Primary accessibility skill for VS Code. REQUIRED for new feature and contribution work, and also applies to updates of existing UI. Covers accessibility help dialogs, accessible views, verbosity settings, signals, ARIA announcements, keyboard navigation, and ARIA labels/roles. --- +## When to Use This Skill + +Use this skill for any VS Code feature work that introduces or changes interactive UI. +Use this skill by default for new features and contributions, including when the request does not explicitly mention accessibility. + +Trigger examples: +- "add a new feature" +- "implement a new panel/view/widget" +- "add a new command or workflow" +- "new contribution in workbench/editor/extensions" +- "update existing UI interactions" + +Do not skip this skill just because accessibility is not named in the prompt. + When adding a **new interactive UI surface** to VS Code — a panel, view, widget, editor overlay, dialog, or any rich focusable component the user interacts with — you **must** provide three accessibility components (if they do not already exist for the feature): 1. **An Accessibility Help Dialog** — opened via the accessibility help keybinding when the feature has focus. @@ -47,10 +61,7 @@ An accessibility help dialog tells the user what the feature does, which keyboar The simplest approach is to return an `AccessibleContentProvider` directly from `getProvider()`. This is the most common pattern in the codebase (used by chat, inline chat, quick chat, etc.): ```ts -import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '…/accessibleView.js'; -import { IAccessibleViewImplementation } from '…/accessibleViewRegistry.js'; -import { AccessibilityVerbositySettingId } from '…/accessibilityConfiguration.js'; -import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId, IAccessibleViewContentProvider, IAccessibleViewOptions } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewType, AccessibleContentProvider, AccessibleViewProviderId } from '../../../../platform/accessibility/browser/accessibleView.js'; import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { AccessibilityVerbositySettingId } from '../../../../platform/accessibility/common/accessibilityConfiguration.js'; diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md new file mode 100644 index 0000000000000..64119b056bad3 --- /dev/null +++ b/.github/skills/add-policy/SKILL.md @@ -0,0 +1,139 @@ +--- +name: add-policy +description: Use when adding, modifying, or reviewing VS Code configuration policies. Covers the full policy lifecycle from registration to export to platform-specific artifacts. Run on ANY change that adds a `policy:` field to a configuration property. +--- + +# Adding a Configuration Policy + +Policies allow enterprise administrators to lock configuration settings via OS-level mechanisms (Windows Group Policy, macOS managed preferences, Linux config files) or via Copilot account-level policy data. This skill covers the complete procedure. + +## When to Use + +- Adding a new `policy:` field to any configuration property +- Modifying an existing policy (rename, category change, etc.) +- Reviewing a PR that touches policy registration +- Adding account-based policy support via `IPolicyData` + +## Architecture Overview + +### Policy Sources (layered, last writer wins) + +| Source | Implementation | How it reads policies | +|--------|---------------|----------------------| +| **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | +| **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function | +| **Multiplex** | `MultiplexPolicyService` | Combines OS-level + account policy services; used in desktop main | + +### Key Files + +| File | Purpose | +|------|---------| +| `src/vs/base/common/policy.ts` | `PolicyCategory` enum, `IPolicy` interface | +| `src/vs/platform/policy/common/policy.ts` | `IPolicyService`, `AbstractPolicyService`, `PolicyDefinition` | +| `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation | +| `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | +| `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | +| `src/vs/base/common/defaultAccount.ts` | `IPolicyData` interface for account-level policy fields | +| `build/lib/policies/policyData.jsonc` | Auto-generated policy catalog (DO NOT edit manually) | +| `build/lib/policies/policyGenerator.ts` | Generates ADMX/ADML (Windows), plist (macOS), JSON (Linux) | +| `build/lib/test/policyConversion.test.ts` | Tests for policy artifact generation | + +## Procedure + +### Step 1 — Add the `policy` field to the configuration property + +Find the configuration registration (typically in a `*.contribution.ts` file) and add a `policy` object to the property schema. + +**Required fields:** + +**Determining `minimumVersion`:** Always read `version` from the root `package.json` and use the `major.minor` portion. For example, if `package.json` has `"version": "1.112.0"`, use `minimumVersion: '1.112'`. Never hardcode an old version like `'1.99'`. + +```typescript +policy: { + name: 'MyPolicyName', // PascalCase, unique across all policies + category: PolicyCategory.InteractiveSession, // From PolicyCategory enum + minimumVersion: '1.112', // Use major.minor from package.json version + localization: { + description: { + key: 'my.config.key', // NLS key for the description + value: nls.localize('my.config.key', "Human-readable description."), + } + } +} +``` + +**Optional: `value` function for account-based policy:** + +If this policy should also be controllable via Copilot account policy data (from `IPolicyData`), add a `value` function: + +```typescript +policy: { + name: 'MyPolicyName', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.112', // Use major.minor from package.json version + value: (policyData) => policyData.my_field === false ? false : undefined, + localization: { /* ... */ } +} +``` + +The `value` function receives `IPolicyData` (from `src/vs/base/common/defaultAccount.ts`) and should: +- Return a concrete value to **override** the user's setting +- Return `undefined` to **not apply** any account-level override (falls through to OS policy or user setting) + +If you need a new field on `IPolicyData`, add it to the interface in `src/vs/base/common/defaultAccount.ts`. + +**Optional: `enumDescriptions` for enum/string policies:** + +```typescript +localization: { + description: { key: '...', value: nls.localize('...', "...") }, + enumDescriptions: [ + { key: 'opt.none', value: nls.localize('opt.none', "No access.") }, + { key: 'opt.all', value: nls.localize('opt.all', "Full access.") }, + ] +} +``` + +### Step 2 — Ensure `PolicyCategory` is imported + +```typescript +import { PolicyCategory } from '../../../../base/common/policy.js'; +``` + +Existing categories in the `PolicyCategory` enum: +- `Extensions` +- `IntegratedTerminal` +- `InteractiveSession` (used for all chat/Copilot policies) +- `Telemetry` +- `Update` + +If you need a new category, add it to `PolicyCategory` in `src/vs/base/common/policy.ts` and add corresponding `PolicyCategoryData` localization. + +### Step 3 — Validate TypeScript compilation + +Check the `VS Code - Build` watch task output, or run: + +```bash +npm run compile-check-ts-native +``` + +### Step 4 — Export the policy data + +Regenerate the auto-generated policy catalog: + +```bash +npm run transpile-client && ./scripts/code.sh --export-policy-data +``` + +This updates `build/lib/policies/policyData.jsonc`. **Never edit this file manually.** Verify your new policy appears in the output. You will need code review from a codeowner to merge the change to main. + + +## Policy for extension-provided settings + +For an extension author to provide policies for their extension's settings, a change must be made in `vscode-distro` to the `product.json`. + +## Examples + +Search the codebase for `policy:` to find all the examples of different policy configurations. diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md index a76794d9c7d8a..af4f03a3f6066 100644 --- a/.github/skills/agent-sessions-layout/SKILL.md +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -45,7 +45,7 @@ When proposing or implementing changes, follow these rules from the spec: 4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar 5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors 6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites -7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`, `ProjectBarPart`), not the standard workbench parts 8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state 9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants @@ -53,20 +53,24 @@ When proposing or implementing changes, follow these rules from the spec: | File | Purpose | |------|---------| -| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/LAYOUT.md` | Authoritative layout specification | | `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | | `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | | `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | | `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | -| `sessions/browser/style.css` | Layout-specific styles | -| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/media/style.css` | Layout-specific styles | +| `sessions/browser/parts/parts.ts` | `AgenticParts` enum | | `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | -| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer and macOS traffic light spacer) | | `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | -| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/browser/parts/auxiliaryBarPart.ts` | Auxiliary Bar part (with run script dropdown) | +| `sessions/browser/parts/panelPart.ts` | Panel part | +| `sessions/browser/parts/projectBarPart.ts` | Project Bar part (folder entries, icon customization) | +| `sessions/contrib/configuration/browser/configuration.contribution.ts` | Sets `workbench.editor.useModal` to `'all'` for modal editor overlay | | `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | -| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script split button for titlebar | | `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | +| `sessions/electron-browser/parts/titlebarPart.ts` | Desktop (Electron) titlebar part | ## 5. Testing Changes diff --git a/.github/skills/azure-pipelines/SKILL.md b/.github/skills/azure-pipelines/SKILL.md index b7b2e164e038d..9790401995258 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) | +| `--parameters ` | Space-separated parameters in `KEY=value KEY2=value2` format | | `--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..fbb74b5dd4aa0 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); } } @@ -612,7 +611,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 +620,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 +770,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 +778,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)'); + console.log(' --parameters Space-separated parameter list in "KEY=value KEY2=value2" format'); 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 +808,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 +837,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 +868,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,8 +883,8 @@ 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(' ')}`); @@ -887,7 +895,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 +912,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 +929,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 +1076,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 +1170,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 +1241,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 +1295,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 +1398,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 +1437,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 +1453,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,12 +1529,12 @@ 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')); }); @@ -1718,7 +1731,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 +1746,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 +1810,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/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md new file mode 100644 index 0000000000000..6c7eb5a6059dc --- /dev/null +++ b/.github/skills/component-fixtures/SKILL.md @@ -0,0 +1,343 @@ +--- +name: component-fixtures +description: Use when creating or updating component fixtures for screenshot testing, or when designing UI components to be fixture-friendly. Covers fixture file structure, theming, service setup, CSS scoping, async rendering, and common pitfalls. +--- + +# Component Fixtures + +Component fixtures render isolated UI components for visual screenshot testing via the component explorer. Fixtures live in `src/vs/workbench/test/browser/componentFixtures/` and are auto-discovered by the Vite dev server using the glob `src/**/*.fixture.ts`. + +Use tools `mcp_component-exp_`* to list and screenshot fixtures. If you cannot see these tools, inform the user to them on. + +## Running Fixtures Locally + +1. Start the component explorer daemon: run the **Launch Component Explorer** task +2. Use the `mcp_component-exp_list_fixtures` tool to see all available fixtures and their URLs +3. Use the `mcp_component-exp_screenshot` tool to capture screenshots programmatically + +## File Structure + +Each fixture file exports a default `defineThemedFixtureGroup(...)`. The file must end with `.fixture.ts`. + +``` +src/vs/workbench/test/browser/componentFixtures/ + fixtureUtils.ts # Shared helpers (DO NOT import @vscode/component-explorer elsewhere) + myComponent.fixture.ts # Your fixture file +``` + +## Basic Pattern + +```typescript +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +export default defineThemedFixtureGroup({ path: 'myFeature/' }, { + Default: defineComponentFixture({ render: renderMyComponent }), + AnotherVariant: defineComponentFixture({ render: renderMyComponent }), +}); + +function renderMyComponent({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '400px'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + // Register additional services the component needs + reg.define(IMyService, MyServiceImpl); + reg.defineInstance(IMockService, mockInstance); + }, + }); + + const widget = disposableStore.add( + instantiationService.createInstance(MyWidget, /* constructor args */) + ); + container.appendChild(widget.domNode); +} +``` + +Key points: +- **`defineThemedFixtureGroup`** automatically creates Dark and Light variants for each fixture +- **`defineComponentFixture`** wraps your render function with theme setup and shadow DOM isolation +- **`createEditorServices`** provides a `TestInstantiationService` with base editor services pre-registered +- Always register created widgets with `disposableStore.add(...)` to prevent leaks +- Pass `colorTheme: theme` to `createEditorServices` so theme colors render correctly + +## Utilities from fixtureUtils.ts + +| Export | Purpose | +|---|---| +| `defineComponentFixture` | Creates Dark/Light themed fixture variants from a render function | +| `defineThemedFixtureGroup` | Groups multiple themed fixtures into a named fixture group | +| `createEditorServices` | Creates `TestInstantiationService` with all base editor services | +| `registerWorkbenchServices` | Registers additional workbench services (context menu, label, etc.) | +| `createTextModel` | Creates a text model via `ModelService` for editor fixtures | +| `setupTheme` | Applies theme CSS to a container (called automatically by `defineComponentFixture`) | +| `darkTheme` / `lightTheme` | Pre-loaded `ColorThemeData` instances | + +**Important:** Only `fixtureUtils.ts` may import from `@vscode/component-explorer`. All fixture files must go through the helpers in `fixtureUtils.ts`. + +## CSS Scoping + +Fixtures render inside shadow DOM. The component-explorer automatically adopts the global VS Code stylesheets and theme CSS. + +### Matching production CSS selectors + +Many VS Code components have CSS rules scoped to deep ancestor selectors (e.g., `.interactive-session .interactive-input-part > .widget-container .my-element`). In fixtures, you must recreate the required ancestor DOM structure for these selectors to match: + +```typescript +function render({ container }: ComponentFixtureContext): void { + container.classList.add('interactive-session'); + + // Recreate ancestor structure that CSS selectors expect + const inputPart = dom.$('.interactive-input-part'); + const widgetContainer = dom.$('.widget-container'); + inputPart.appendChild(widgetContainer); + container.appendChild(inputPart); + + widgetContainer.appendChild(myWidget.domNode); +} +``` + +**Design recommendation for new components:** Avoid deeply nested CSS selectors that require specific ancestor elements. Use self-contained class names (e.g., `.my-widget .my-element` rather than `.parent-view .parent-part > .wrapper .my-element`). This makes components easier to fixture and reuse. + +## Services + +### Using createEditorServices + +`createEditorServices` pre-registers these services: `IAccessibilityService`, `IKeybindingService`, `IClipboardService`, `IOpenerService`, `INotificationService`, `IDialogService`, `IUndoRedoService`, `ILanguageService`, `IConfigurationService`, `IStorageService`, `IThemeService`, `IModelService`, `ICodeEditorService`, `IContextKeyService`, `ICommandService`, `ITelemetryService`, `IHoverService`, `IUserInteractionService`, and more. + +### Additional services + +Register extra services via `additionalServices`: + +```typescript +createEditorServices(disposableStore, { + additionalServices: (reg) => { + // Class-based (instantiated by DI): + reg.define(IMyService, MyServiceImpl); + // Instance-based (pre-constructed): + reg.defineInstance(IMyService, myMockInstance); + }, +}); +``` + +### Mocking services + +Use the `mock()` helper from `base/test/common/mock.js` to create mock service instances: + +```typescript +import { mock } from '../../../../base/test/common/mock.js'; + +const myService = new class extends mock() { + override someMethod(): string { return 'test'; } + override onSomeEvent = Event.None; +}; +reg.defineInstance(IMyService, myService); +``` + +For mock view models or data objects: +```typescript +const element = new class extends mock() { }(); +``` + +## Async Rendering + +The component explorer waits **2 animation frames** after the synchronous render function returns. For most components, this is sufficient. + +If your render function returns a `Promise`, the component explorer waits for the promise to resolve. + +### Pitfall: DOM reparenting causes flickering + +Avoid moving rendered widgets between DOM parents after initial render. This causes: +- Layout recalculation (the widget jumps as `position: absolute` coordinates become invalid) +- Focus loss (blur events can trigger hide logic in widgets like QuickInput) +- Screenshot instability (the component explorer may capture an intermediate layout state) + +**Bad pattern — reparenting a widget after async wait:** +```typescript +async function render({ container }: ComponentFixtureContext): Promise { + const host = document.createElement('div'); + container.appendChild(host); + // ... create widget inside host ... + await waitForWidget(); + container.appendChild(widget); // BAD: reparenting causes flicker + host.remove(); +} +``` + +**Better pattern — render in-place with the correct DOM structure from the start:** +```typescript +function render({ container }: ComponentFixtureContext): void { + // Set up the correct DOM structure first, then create the widget inside it + const widget = createWidget(container); + container.appendChild(widget.domNode); +} +``` + +If the component absolutely requires async setup (e.g., QuickInput which renders internally), minimize DOM manipulation after the widget appears by structuring the host container to match the final layout from the beginning. + +## Adapting Existing Components for Fixtures + +Existing components often need small changes to become fixturable. When writing a fixture reveals friction, fix the component — don't work around it in the fixture. Common adaptations: + +### Decouple CSS from ancestor context + +If a component's CSS only works inside a deeply nested selector like `.workbench .sidebar .my-view .my-widget`, refactor the CSS to be self-contained. Move the styles so they're scoped to the component's own root class: + +```css +/* Before: requires specific ancestors */ +.workbench .sidebar .my-view .my-widget .header { font-weight: bold; } + +/* After: self-contained */ +.my-widget .header { font-weight: bold; } +``` + +If the component shares styles with its parent (e.g., inheriting background color), use CSS custom properties rather than relying on ancestor selectors. + +### Extract hard-coded service dependencies + +If a component reaches into singletons or global state instead of using DI, refactor it to accept services through the constructor: + +```typescript +// Before: hard to mock in fixtures +class MyWidget { + private readonly config = getSomeGlobalConfig(); +} + +// After: injectable and testable +class MyWidget { + constructor(@IConfigurationService private readonly configService: IConfigurationService) { } +} +``` + +### Add options to control auto-focus and animation + +Components that auto-focus on creation or run animations cause flaky screenshots. Add an options parameter: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; +} +``` + +The fixture passes `shouldAutoFocus: false`. The production call site keeps the default behavior. + +### Expose internal state for "already completed" rendering + +Many components have lifecycle states (loading → active → completed). If the component can only reach the "completed" state through user interaction, add support for initializing directly into that state via constructor data: + +```typescript +// The fixture can pass pre-filled data to render the summary/completed state +// without simulating the full user interaction flow. +const carousel: IChatQuestionCarousel = { + questions, + allowSkip: true, + kind: 'questionCarousel', + isUsed: true, // Already completed + data: { 'q1': 'answer' }, // Pre-filled answers +}; +``` + +### Make DOM node accessible + +If a component builds its DOM internally and doesn't expose the root element, add a public `readonly domNode: HTMLElement` property so fixtures can append it to the container. + +## Writing Fixture-Friendly Components + +When designing new UI components, follow these practices to make them easy to fixture: + +### 1. Accept a container element in the constructor + +```typescript +// Good: container is passed in +class MyWidget { + constructor(container: HTMLElement, @IFoo foo: IFoo) { + this.domNode = dom.append(container, dom.$('.my-widget')); + } +} + +// Also good: widget creates its own domNode for the caller to place +class MyWidget { + readonly domNode: HTMLElement; + constructor(@IFoo foo: IFoo) { + this.domNode = dom.$('.my-widget'); + } +} +``` + +### 2. Use dependency injection for all services + +All external dependencies should come through DI so fixtures can provide test implementations: + +```typescript +// Good: services injected +constructor(@IThemeService private readonly themeService: IThemeService) { } + +// Bad: reaching into globals +constructor() { this.theme = getGlobalTheme(); } +``` + +### 3. Keep CSS selectors shallow + +```css +/* Good: self-contained, easy to fixture */ +.my-widget .my-header { ... } +.my-widget .my-list-item { ... } + +/* Bad: requires deep ancestor chain */ +.workbench .sidebar .my-view .my-widget .my-header { ... } +``` + +### 4. Avoid reading from layout/window services during construction + +Components that measure the window or read layout dimensions during construction are hard to fixture because the shadow DOM container has different dimensions than the workbench: + +```typescript +// Prefer: use CSS for sizing, or accept dimensions as parameters +container.style.width = '400px'; +container.style.height = '300px'; + +// Avoid: reading from layoutService during construction +const width = this.layoutService.mainContainerDimension.width; +``` + +### 5. Support disabling auto-focus in fixtures + +Auto-focus can interfere with screenshot stability. Provide options to disable it: + +```typescript +interface IMyWidgetOptions { + shouldAutoFocus?: boolean; // Fixtures pass false +} +``` + +### 6. Expose the DOM node + +The fixture needs to append the widget's DOM to the container. Expose it as a public `readonly domNode: HTMLElement`. + +## Multiple Fixture Variants + +Create variants to show different states of the same component: + +```typescript +export default defineThemedFixtureGroup({ + // Different data states + Empty: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: [] }) }), + WithItems: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: sampleItems }) }), + + // Different configurations + ReadOnly: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: true }) }), + Editable: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: false }) }), + + // Lifecycle states + Loading: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'loading' }) }), + Completed: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'done' }) }), +}); +``` + +## Learnings + +Update this section with insights from your fixture development experience! + +* Do not copy the component to the fixture and modify it there. Always adapt the original component to be fixture-friendly, then render it in the fixture. This ensures the fixture tests the real component code and lifecycle, rather than a modified version that may hide bugs. + +* **Don't recompose child widgets in fixtures.** Never manually instantiate and add a sub-widget (e.g., a toolbar content widget) that the parent component is supposed to create. Instead, configure the parent correctly (e.g., set the right editor option, register the right provider) so the child appears through the normal code path. Manually recomposing hides integration bugs and doesn't test the real widget lifecycle. diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index fc49548b7a384..e39957e5f66bc 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -15,8 +15,6 @@ The `src/vs/sessions/` directory contains authoritative specification documents. | Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | | AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | | Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | -| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | -| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. @@ -62,44 +60,57 @@ src/vs/sessions/ ├── AI_CUSTOMIZATIONS.md # AI customization design document ├── sessions.common.main.ts # Common (browser + desktop) entry point ├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) -├── common/ # Shared types and context keys -│ └── contextkeys.ts # ChatBar context keys +├── common/ # Shared types, context keys, and theme +│ ├── categories.ts # Command categories +│ ├── contextkeys.ts # ChatBar and welcome context keys +│ └── theme.ts # Theme contributions ├── browser/ # Core workbench implementation │ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) │ ├── menus.ts # Agent sessions menu IDs (Menus export) │ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) │ ├── paneCompositePartService.ts # AgenticPaneCompositePartService -│ ├── style.css # Layout-specific styles │ ├── widget/ # Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc -│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget -│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state -│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -│ │ └── media/ -│ └── parts/ # Workbench part implementations -│ ├── parts.ts # AgenticParts enum -│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) -│ ├── sidebarPart.ts # Sidebar (with footer for account widget) -│ ├── chatBarPart.ts # Chat Bar (primary chat surface) -│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) -│ ├── panelPart.ts # Panel (terminal, output, etc.) -│ ├── projectBarPart.ts # Project bar (folder entries) -│ ├── agentSessionsChatInputPart.ts # Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) -│ └── media/ # Part CSS files +│ │ └── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ ├── parts/ # Workbench part implementations +│ │ ├── parts.ts # AgenticParts enum +│ │ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ │ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ │ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ │ ├── auxiliaryBarPart.ts # Auxiliary Bar +│ │ ├── panelPart.ts # Panel (terminal, output, etc.) +│ │ ├── projectBarPart.ts # Project bar (folder entries) +│ │ └── media/ # Part CSS files +│ └── media/ # Layout-specific styles ├── electron-browser/ # Desktop-specific entry points │ ├── sessions.main.ts # Desktop main bootstrap │ ├── sessions.ts # Electron process entry │ ├── sessions.html # Production HTML shell -│ └── sessions-dev.html # Development HTML shell +│ ├── sessions-dev.html # Development HTML shell +│ ├── titleService.ts # Desktop title service override +│ └── parts/ +│ └── titlebarPart.ts # Desktop titlebar part +├── services/ # Service overrides +│ ├── configuration/browser/ # Configuration service overrides +│ └── workspace/browser/ # Workspace service overrides +├── test/ # Unit tests +│ └── browser/ +│ └── layoutActions.test.ts └── contrib/ # Feature contributions ├── accountMenu/browser/ # Account widget for sidebar footer - ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── agentFeedback/browser/ # Agent feedback attachments, overlays, hover ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── applyToParentRepo/browser/ # Apply changes to parent repo ├── changesView/browser/ # File changes view ├── chat/browser/ # Chat actions (run script, branch, prompts) ├── configuration/browser/ # Configuration overrides - └── sessions/browser/ # Sessions view, title bar widget, active session service + ├── files/browser/ # File-related contributions + ├── fileTreeView/browser/ # File tree view (filesystem provider) + ├── gitSync/browser/ # Git sync contributions + ├── logs/browser/ # Log contributions + ├── sessions/browser/ # Sessions view, title bar widget, active session service + ├── terminal/browser/ # Terminal contributions + ├── welcome/browser/ # Welcome view contribution + └── workspace/browser/ # Workspace contributions ``` ## 4. Layout @@ -165,18 +176,21 @@ The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts | Menu ID | Purpose | |---------|---------| -| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | -| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | -| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.ChatBarTitle` | Chat bar title actions | | `Menus.CommandCenter` | Center toolbar with session picker widget | -| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.CommandCenterCenter` | Center section of command center | +| `Menus.TitleBarContext` | Titlebar context menu | +| `Menus.TitleBarLeftLayout` | Left layout toolbar | +| `Menus.TitleBarSessionTitle` | Session title in titlebar | +| `Menus.TitleBarSessionMenu` | Session menu in titlebar | +| `Menus.TitleBarRightLayout` | Right layout toolbar | | `Menus.PanelTitle` | Panel title bar actions | | `Menus.SidebarTitle` | Sidebar title bar actions | | `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.SidebarCustomizations` | Sidebar customizations menu | | `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | | `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | -| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | -| `Menus.ChatBarTitle` | Chat bar title actions | +| `Menus.AgentFeedbackEditorContent` | Agent feedback editor content menu | ## 7. Context Keys @@ -187,7 +201,7 @@ Defined in `common/contextkeys.ts`: | `activeChatBar` | `string` | ID of the active chat bar panel | | `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | | `chatBarVisible` | `boolean` | Whether chat bar is visible | - +| `sessionsWelcomeVisible` | `boolean` | Whether the sessions welcome overlay is visible | ## 8. Contributions Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). @@ -199,13 +213,18 @@ Feature contributions live under `contrib//browser/` and are regist | **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | | **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | | **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | -| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | -| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | -| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | -| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Chat Actions** | `contrib/chat/browser/` | Chat actions (run script, branch, prompts, customizations debug log) | | **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | -| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **Agent Feedback** | `contrib/agentFeedback/browser/` | Agent feedback attachments, editor overlays, hover | | **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Apply to Parent Repo** | `contrib/applyToParentRepo/browser/` | Apply changes to parent repo | +| **Files** | `contrib/files/browser/` | File-related contributions | +| **File Tree View** | `contrib/fileTreeView/browser/` | File tree view (filesystem provider) | +| **Git Sync** | `contrib/gitSync/browser/` | Git sync contributions | +| **Logs** | `contrib/logs/browser/` | Log contributions | +| **Terminal** | `contrib/terminal/browser/` | Terminal contributions | +| **Welcome** | `contrib/welcome/browser/` | Welcome view contribution | +| **Workspace** | `contrib/workspace/browser/` | Workspace contributions | | **Configuration** | `contrib/configuration/browser/` | Configuration overrides | ### 8.2 Service Overrides @@ -216,6 +235,10 @@ The agent sessions window registers its own implementations for: - `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) - `IActiveSessionService` → `ActiveSessionService` (tracks active session) +Service overrides also live under `services/`: +- `services/configuration/browser/` - configuration service overrides +- `services/workspace/browser/` - workspace service overrides + ### 8.3 `WindowVisibility.Sessions` Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. @@ -224,12 +247,14 @@ Views and contributions that should only appear in the agent sessions window (no | File | Purpose | |------|---------| -| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | -| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `sessions.common.main.ts` | Common entry; imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry; imports desktop services, electron contributions, all `contrib/` modules | | `electron-browser/sessions.main.ts` | Desktop bootstrap | | `electron-browser/sessions.ts` | Electron process entry | | `electron-browser/sessions.html` | Production HTML shell | | `electron-browser/sessions-dev.html` | Development HTML shell | +| `electron-browser/titleService.ts` | Desktop title service override | +| `electron-browser/parts/titlebarPart.ts` | Desktop titlebar part | ## 10. Development Guidelines @@ -243,7 +268,15 @@ Views and contributions that should only appear in the agent sessions window (no 6. Use agent session part classes, not standard workbench parts 7. Mark views with `WindowVisibility.Sessions` so they only appear in this window -### 10.2 Layout Changes +### 10.2 Validating Changes + +1. Run `npm run compile-check-ts-native` to run a repo-wide TypeScript compilation check (including `src/vs/sessions/`). This is a fast way to catch TypeScript errors introduced by your changes. +2. Run `npm run valid-layers-check` to verify layering rules are not violated. +3. Use `scripts/test.sh` (or `scripts\test.bat` on Windows) for unit tests (add `--grep ` to filter tests) + +**Important** do not run `tsc` to check for TypeScript errors always use above methods to validate TypeScript changes in `src/vs/**`. + +### 10.3 Layout Changes 1. **Read `LAYOUT.md` first** — it's the authoritative spec 2. Use the `agent-sessions-layout` skill for detailed implementation guidance diff --git a/.github/skills/update-screenshots/SKILL.md b/.github/skills/update-screenshots/SKILL.md index 46172cfee2d9b..294125273ef12 100644 --- a/.github/skills/update-screenshots/SKILL.md +++ b/.github/skills/update-screenshots/SKILL.md @@ -72,7 +72,16 @@ git add test/componentFixtures/.screenshots/baseline/ git commit -m "update screenshot baselines from CI" ``` -### 7. Verify +### 7. Push LFS objects before pushing + +Screenshot baselines are stored in Git LFS. The `git lfs pre-push` hook is not active in this repo (husky overwrites it), so LFS objects are NOT automatically uploaded on `git push`. You must push them manually before pushing the branch, otherwise the push will fail with `GH008: Your push referenced unknown Git LFS objects`. + +```bash +git lfs push --all origin +git push +``` + +### 8. Verify Confirm the baselines are updated by listing the files: diff --git a/.github/workflows/api-proposal-version-check.yml b/.github/workflows/api-proposal-version-check.yml new file mode 100644 index 0000000000000..ee082dee49f5b --- /dev/null +++ b/.github/workflows/api-proposal-version-check.yml @@ -0,0 +1,298 @@ +name: API Proposal Version Check + +on: + pull_request: + branches: + - main + - 'release/*' + paths: + - 'src/vscode-dts/vscode.proposed.*.d.ts' + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + actions: write + +concurrency: + group: api-proposal-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + check-version-changes: + name: Check API Proposal Version Changes + # Run on PR events, or on issue_comment if it's on a PR and contains the override command + if: | + github.event_name == 'pull_request' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/api-proposal-change-required') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR')) + runs-on: ubuntu-latest + steps: + - name: Get PR info + id: pr_info + uses: actions/github-script@v8 + with: + script: | + let prNumber, headSha, baseSha; + + if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + headSha = context.payload.pull_request.head.sha; + baseSha = context.payload.pull_request.base.sha; + } else { + // issue_comment event - need to fetch PR details + prNumber = context.payload.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + headSha = pr.head.sha; + baseSha = pr.base.sha; + } + + core.setOutput('number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('base_sha', baseSha); + + - name: Check for override comment + id: check_override + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + // Only accept overrides from trusted users (repo members/collaborators) + const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; + let overrideComment = null; + const untrustedOverrides = []; + + comments.forEach((comment, index) => { + const hasOverrideText = comment.body.includes('/api-proposal-change-required'); + const isTrusted = trustedAssociations.includes(comment.author_association); + console.log(`Comment ${index + 1}:`); + console.log(` Author: ${comment.user.login}`); + console.log(` Author association: ${comment.author_association}`); + console.log(` Created at: ${comment.created_at}`); + console.log(` Contains override command: ${hasOverrideText}`); + console.log(` Author is trusted: ${isTrusted}`); + console.log(` Would be valid override: ${hasOverrideText && isTrusted}`); + + if (hasOverrideText) { + if (isTrusted && !overrideComment) { + overrideComment = comment; + } else if (!isTrusted) { + untrustedOverrides.push(comment); + } + } + }); + + if (overrideComment) { + console.log(`✅ Override comment FOUND`); + console.log(` Comment ID: ${overrideComment.id}`); + console.log(` Author: ${overrideComment.user.login}`); + console.log(` Association: ${overrideComment.author_association}`); + console.log(` Created at: ${overrideComment.created_at}`); + core.setOutput('override_found', 'true'); + core.setOutput('override_user', overrideComment.user.login); + } else { + if (untrustedOverrides.length > 0) { + console.log(`⚠️ Found ${untrustedOverrides.length} override comment(s) from UNTRUSTED user(s):`); + untrustedOverrides.forEach((comment, index) => { + console.log(` Untrusted override ${index + 1}:`); + console.log(` Author: ${comment.user.login}`); + console.log(` Association: ${comment.author_association}`); + console.log(` Created at: ${comment.created_at}`); + console.log(` Comment ID: ${comment.id}`); + }); + console.log(` Trusted associations are: ${trustedAssociations.join(', ')}`); + } + console.log('❌ No valid override comment found'); + core.setOutput('override_found', 'false'); + } + + # If triggered by the override comment, re-run the failed workflow to update its status + # Only allow trusted users to trigger re-runs to prevent spam + - name: Re-run failed workflow on override + if: | + steps.check_override.outputs.override_found == 'true' && + github.event_name == 'issue_comment' && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + uses: actions/github-script@v8 + with: + script: | + const headSha = '${{ steps.pr_info.outputs.head_sha }}'; + console.log(`Override comment found by ${{ steps.check_override.outputs.override_user }}`); + console.log('API proposal version change has been acknowledged.'); + + // Find the failed workflow run for this PR's head SHA + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'api-proposal-version-check.yml', + head_sha: headSha, + status: 'completed', + per_page: 10 + }); + + // Find the most recent failed run + const failedRun = runs.workflow_runs.find(run => + run.conclusion === 'failure' && run.event === 'pull_request' + ); + + if (failedRun) { + console.log(`Re-running failed workflow run ${failedRun.id}`); + await github.rest.actions.reRunWorkflow({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: failedRun.id + }); + console.log('Workflow re-run triggered successfully'); + } else { + console.log('No failed pull_request workflow run found to re-run'); + // The check will pass on this run since override exists + } + + - name: Pass on override comment + if: steps.check_override.outputs.override_found == 'true' + run: | + echo "Override comment found by ${{ steps.check_override.outputs.override_user }}" + echo "API proposal version change has been acknowledged." + + # Only continue checking if no override found + - name: Checkout repository + if: steps.check_override.outputs.override_found != 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes + if: steps.check_override.outputs.override_found != 'true' + id: version_check + env: + HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }} + BASE_SHA: ${{ steps.pr_info.outputs.base_sha }} + run: | + set -e + + # Use merge-base to get accurate diff of what the PR actually changes + MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + echo "Merge base: $MERGE_BASE" + + # Get the list of changed proposed API files (diff against merge-base) + CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- 'src/vscode-dts/vscode.proposed.*.d.ts' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "No proposed API files changed" + echo "version_changed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Changed proposed API files:" + echo "$CHANGED_FILES" + + VERSION_CHANGED="false" + CHANGED_LIST="" + + for FILE in $CHANGED_FILES; do + # Check if file exists in head + if ! git cat-file -e "$HEAD_SHA:$FILE" 2>/dev/null; then + echo "File $FILE was deleted, skipping version check" + continue + fi + + # Get version from head (current PR) + HEAD_VERSION=$(git show "$HEAD_SHA:$FILE" | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + # Get version from merge-base (what the PR is based on) + BASE_VERSION=$(git show "$MERGE_BASE:$FILE" 2>/dev/null | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + echo "File: $FILE" + echo " Base version: ${BASE_VERSION:-'(none)'}" + echo " Head version: ${HEAD_VERSION:-'(none)'}" + + # Check if version was added or changed + if [ -n "$HEAD_VERSION" ] && [ "$HEAD_VERSION" != "$BASE_VERSION" ]; then + echo " -> Version changed!" + VERSION_CHANGED="true" + FILENAME=$(basename "$FILE") + if [ -n "$CHANGED_LIST" ]; then + CHANGED_LIST="$CHANGED_LIST, $FILENAME" + else + CHANGED_LIST="$FILENAME" + fi + fi + done + + echo "version_changed=$VERSION_CHANGED" >> $GITHUB_OUTPUT + echo "changed_files=$CHANGED_LIST" >> $GITHUB_OUTPUT + + - name: Post warning comment + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + uses: actions/github-script@v8 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const changedFiles = '${{ steps.version_check.outputs.changed_files }}'; + + // Check if we already posted a warning comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const marker = ''; + const existingComment = comments.find(comment => + comment.body.includes(marker) + ); + + const body = `${marker} + ## ⚠️ API Proposal Version Change Detected + + The following proposed API files have version changes: **${changedFiles}** + + API proposal version changes should only be used when maintaining compatibility is not possible. Consider keeping the version as is and maintaining backward compatibility. + + **Any version changes must be adopted by the consuming extensions before the next insiders for the extension to work.** + + --- + + If the version change is required, comment \`/api-proposal-change-required\` to unblock this check and acknowledge that you will update any critical consuming extensions (Copilot Chat).`; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + console.log('Updated existing warning comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + console.log('Posted new warning comment'); + } + + - name: Fail if version changed without override + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + run: | + echo "::error::API proposal version changed in: ${{ steps.version_check.outputs.changed_files }}" + echo "To unblock, comment '/api-proposal-change-required' on the PR." + exit 1 diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml index 45d1ae55f623b..be9cf34d0777a 100644 --- a/.github/workflows/no-engineering-system-changes.yml +++ b/.github/workflows/no-engineering-system-changes.yml @@ -21,22 +21,52 @@ jobs: echo "engineering_systems_modified=false" >> $GITHUB_OUTPUT echo "No engineering systems were modified in this PR" fi + - name: Allow automated distro updates + id: distro_exception + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login == 'vs-code-engineering[bot]' }} + run: | + # Allow the vs-code-engineering bot ONLY when package.json is the + # sole changed file and the diff exclusively touches the "distro" field. + ONLY_PKG=$(jq -e '. == ["package.json"]' "$HOME/files.json" > /dev/null 2>&1 && echo true || echo false) + if [[ "$ONLY_PKG" != "true" ]]; then + echo "Bot modified files beyond package.json — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + DIFF=$(gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }}) || { + echo "Failed to fetch PR diff — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + exit 0 + } + CHANGED_LINES=$(echo "$DIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | wc -l) + DISTRO_LINES=$(echo "$DIFF" | grep -cE '^[+-][[:space:]]*"distro"[[:space:]]*:' || true) + + if [[ "$CHANGED_LINES" -eq 2 && "$DISTRO_LINES" -eq 2 ]]; then + echo "Distro-only update by bot — allowing" + echo "allowed=true" >> $GITHUB_OUTPUT + else + echo "Bot changed more than the distro field — not allowed" + echo "allowed=false" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Prevent Copilot from modifying engineering systems - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login == 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'Copilot' }} run: | echo "Copilot is not allowed to modify .github/workflows, build folder files, or package.json files." echo "If you need to update engineering systems, please do so manually or through authorized means." exit 1 - uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 id: get_permissions - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} with: route: GET /repos/microsoft/vscode/collaborators/${{ github.event.pull_request.user.login }}/permission env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set control output variable id: control - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }} run: | echo "user: ${{ github.event.pull_request.user.login }}" echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}" @@ -44,7 +74,7 @@ jobs: echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}" echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT - name: Check for engineering system changes - if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.control.outputs.should_run == 'true' }} + if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.distro_exception.outputs.allowed != 'true' && steps.control.outputs.should_run == 'true' }} run: | echo "Changes to .github/workflows/, build/ folder files, or package.json files aren't allowed in PRs." exit 1 diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c876d2a3782be..56cd6e6ba2eb4 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -212,7 +212,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -223,7 +223,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -232,7 +232,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index df6ab20e586b5..7922ec107f90a 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -258,7 +258,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 +269,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 +278,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index bd4a62d42fa26..2bde317b4806a 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -249,7 +249,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -260,7 +260,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b988d19f49ea6..bb87ac077bce2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -77,7 +77,7 @@ jobs: working-directory: build - name: Compile & Hygiene - run: npm exec -- npm-run-all2 -lp core-ci extensions-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts + run: npm exec -- npm-run-all2 -lp core-ci hygiene eslint valid-layers-check define-class-fields-check vscode-dts-compile-check tsec-compile-check test-build-scripts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index a45f8d38133bb..01f186a1c8146 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -1,4 +1,4 @@ -name: Screenshot Tests +name: Checking Component Screenshots on: push: @@ -10,8 +10,6 @@ on: permissions: contents: read - pull-requests: write - checks: write statuses: write concurrency: @@ -20,15 +18,16 @@ concurrency: jobs: screenshots: + name: Checking Component Screenshots runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: lfs: true - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: .nvmrc @@ -55,12 +54,12 @@ jobs: run: npx playwright install chromium - name: Capture screenshots - run: npx component-explorer screenshot --project ./test/componentFixtures/component-explorer.json + run: ./node_modules/.bin/component-explorer screenshot --project ./test/componentFixtures/component-explorer.json - name: Compare screenshots id: compare run: | - npx component-explorer screenshot:compare \ + ./node_modules/.bin/component-explorer screenshot:compare \ --project ./test/componentFixtures \ --report ./test/componentFixtures/.screenshots/report continue-on-error: true @@ -74,14 +73,14 @@ jobs: fi - name: Upload explorer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: component-explorer path: /tmp/explorer-artifact/ - name: Upload screenshot report if: steps.compare.outcome == 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: screenshot-diff path: | @@ -93,41 +92,35 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | REPORT="test/componentFixtures/.screenshots/report/report.json" + STATE="success" if [ -f "$REPORT" ]; then CHANGED=$(node -e "const r = require('./$REPORT'); console.log(r.summary.added + r.summary.removed + r.summary.changed)") - TITLE="${CHANGED} screenshots changed" + TITLE="⚠ ${CHANGED} screenshots changed" + BLOCKS_CI=$(node -e " + const r = require('./$REPORT'); + const blocking = Object.entries(r.fixtures).filter(([, f]) => + f.status !== 'unchanged' && (f.labels || []).includes('blocks-ci') + ); + if (blocking.length > 0) { + console.log(blocking.map(([name]) => name).join(', ')); + } + ") + if [ -n "$BLOCKS_CI" ]; then + STATE="failure" + TITLE="❌ ${CHANGED} screenshots changed (blocks CI: ${BLOCKS_CI})" + fi else - TITLE="Screenshots match" + TITLE="✅ Screenshots match" fi SHA="${{ github.event.pull_request.head.sha || github.sha }}" - CHECK_RUN_ID=$(gh api "repos/${{ github.repository }}/commits/$SHA/check-runs" \ - --jq '.check_runs[] | select(.name == "screenshots") | .id') + DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json&search=changed" - DETAILS_URL="https://hediet-ghartifactpreview.azurewebsites.net/${{ github.repository }}/run/${{ github.run_id }}/component-explorer/___explorer.html?report=./screenshot-report/report.json" - - if [ -n "$CHECK_RUN_ID" ]; then - gh api "repos/${{ github.repository }}/check-runs/$CHECK_RUN_ID" \ - -X PATCH --input - <> $GITHUB_STEP_SUMMARY - else - echo "## Screenshots ✅" >> $GITHUB_STEP_SUMMARY - echo "No visual changes detected." >> $GITHUB_STEP_SUMMARY - fi - # - name: Post PR comment # if: github.event_name == 'pull_request' # env: diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml new file mode 100644 index 0000000000000..b7047a8e30a07 --- /dev/null +++ b/.github/workflows/sessions-e2e.yml @@ -0,0 +1,66 @@ +name: Sessions E2E Tests + +on: + pull_request: + branches: + - main + - 'release/*' + paths: + - 'src/vs/sessions/**' + - 'scripts/code-sessions-web.*' + +permissions: + contents: read + +concurrency: + group: sessions-e2e-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + sessions-e2e: + name: Sessions E2E Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Install build tools + run: sudo apt update -y && sudo apt install -y build-essential pkg-config libx11-dev libx11-xcb-dev libxkbfile-dev libnotify-bin libkrb5-dev xvfb + + - name: Install dependencies + run: npm ci + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install build dependencies + run: npm ci + working-directory: build + + - name: Transpile sources + run: npm run transpile-client + + - name: Install E2E test dependencies + run: npm ci + working-directory: src/vs/sessions/test/e2e + + - name: Install Playwright browsers + run: npx playwright install chromium + + - name: Run Sessions E2E tests + run: xvfb-run npm test + working-directory: src/vs/sessions/test/e2e + + - name: Upload failure screenshots + if: failure() + uses: actions/upload-artifact@v7 + with: + name: sessions-e2e-failures + path: src/vs/sessions/test/e2e/out/failure-*.png + retention-days: 7 diff --git a/.gitignore b/.gitignore index 9a9fdcadff97a..e5ca4dd32cc0b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,22 @@ test-output.json test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ dist +.playwright-cli +.agents/agents/*.local.md +.claude/agents/*.local.md +.github/agents/*.local.md +.agents/agents/*.local.agent.md +.claude/agents/*.local.agent.md +.github/agents/*.local.agent.md +.agents/hooks/*.local.json +.claude/hooks/*.local.json +.github/hooks/*.local.json +.agents/instructions/*.local.instructions.md +.claude/instructions/*.local.instructions.md +.github/instructions/*.local.instructions.md +.agents/prompts/*.local.prompt.md +.claude/prompts/*.local.prompt.md +.github/prompts/*.local.prompt.md +.agents/skills/.local/ +.claude/skills/.local/ +.github/skills/.local/ diff --git a/.npmrc b/.npmrc index b07eade64d573..a275846ab5c04 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.0" +ms_build_id="13470701" runtime="electron" ignore-scripts=false build_from_source="true" 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..8927b0b7064a9 --- /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], { + 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..47d901042e3a8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -278,6 +278,51 @@ "hidden": true, }, }, + { + "type": "chrome", + "request": "launch", + "name": "Launch VS Sessions Internal", + "windows": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.bat" + }, + "osx": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "linux": { + "runtimeExecutable": "${workspaceFolder}/scripts/code.sh" + }, + "port": 9222, + "timeout": 0, + "env": { + "VSCODE_EXTHOST_WILL_SEND_SOCKET": null, + "VSCODE_SKIP_PRELAUNCH": "1", + "VSCODE_DEV_DEBUG_OBSERVABLES": "1", + }, + "cleanUp": "wholeBrowser", + "killBehavior": "polite", + "runtimeArgs": [ + "--inspect-brk=5875", + "--no-cached-data", + "--crash-reporter-directory=${workspaceFolder}/.profile-oss/crashes", + // for general runtime freezes: https://github.com/microsoft/vscode/issues/127861#issuecomment-904144910 + "--disable-features=CalculateNativeWinOcclusion", + "--disable-extension=vscode.vscode-api-tests", + "--sessions" + ], + "userDataDir": "${userHome}/.vscode-oss-sessions-dev", + "webRoot": "${workspaceFolder}", + "cascadeTerminateToConfigurations": [ + "Attach to Extension Host" + ], + "pauseForSourceMap": false, + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "browserLaunchLocation": "workspace", + "presentation": { + "hidden": true, + }, + }, { // To debug observables you also need the extension "ms-vscode.debug-value-editor" "type": "chrome", @@ -603,9 +648,19 @@ } }, { - "name": "Component Explorer", + "name": "Component Explorer (Edge)", "type": "msedge", - "port": 9230, + "request": "launch", + "url": "http://localhost:5337/___explorer", + "preLaunchTask": "Launch Component Explorer", + "presentation": { + "group": "1_component_explorer", + "order": 4 + } + }, + { + "name": "Component Explorer (Chrome)", + "type": "chrome", "request": "launch", "url": "http://localhost:5337/___explorer", "preLaunchTask": "Launch Component Explorer", @@ -653,6 +708,21 @@ "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": { + "group": "0_vscode", + "order": 1 + } + }, { "name": "VS Code (Hot Reload)", "stopAll": true, diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index d3f716f5749cb..96e5e5690d0ea 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"February 2026\"" + "value": "$MILESTONE=milestone:\"1.112.0\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index b58910ad675e5..e3ddd3af411af 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.112.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:Tyriar -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..c4bc569e9da31 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:\"March 2026\"\n" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index 74343459e02ef..bd45f6441fa04 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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..687521f7e649c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,8 +225,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inSessions": true + "problemMatcher": [] }, { "label": "Run Dev Sessions", @@ -238,6 +237,18 @@ "args": [ "--sessions" ], + "problemMatcher": [] + }, + { + "label": "Run and Compile Dev Sessions", + "type": "shell", + "command": "npm run transpile-client && ./scripts/code.sh", + "windows": { + "command": "npm run transpile-client && .\\scripts\\code.bat" + }, + "args": [ + "--sessions" + ], "inSessions": true, "problemMatcher": [] }, @@ -375,9 +386,35 @@ { "label": "Launch Component Explorer", "type": "shell", - "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json", + "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json -vv --kill-if-running", "isBackground": true, - "problemMatcher": [] + "problemMatcher": { + "owner": "component-explorer", + "fileLocation": "absolute", + "pattern": { + "regexp": "^\\s*at\\s+(.+?):(\\d+):(\\d+)\\s*$", + "file": 1, + "line": 2, + "column": 3 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*Setting up sessions.*", + "endsPattern": "Redirection server listening on.*" + } + } + }, + { + "label": "Install & Watch", + "type": "shell", + "command": "npm install && npm run watch", + "windows": { + "command": "cmd /d /c \"npm install && npm run watch\"" + }, + "inSessions": true, + "runOptions": { + "runOn": "worktreeCreated" + } } ] } diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 896b59001d616..0a15b3ff5fc30 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -684,7 +684,7 @@ more details. --------------------------------------------------------- -go-syntax 0.8.5 - MIT +go-syntax 0.8.6 - MIT https://github.com/worlpaker/go-syntax MIT License 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..ce6d95dd7e59d 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 }} + displayName: Publish Screenshots + condition: succeededOrFailed() + continueOnError: true + sbomEnabled: false variables: TEST_DIR: $(Build.SourcesDirectory)/test/sanity LOG_FILE: $(TEST_DIR)/results.xml + SCREENSHOTS_DIR: $(TEST_DIR)/screenshots DOCKER_CACHE_DIR: $(Pipeline.Workspace)/docker-cache DOCKER_CACHE_FILE: $(DOCKER_CACHE_DIR)/${{ parameters.container }}.tar steps: - checkout: self fetchDepth: 1 fetchTags: false - sparseCheckoutDirectories: test/sanity .nvmrc + sparseCheckoutDirectories: build/azure-pipelines/config test/sanity .nvmrc displayName: Checkout test/sanity + - ${{ if eq(parameters.os, 'windows') }}: + - script: mkdir "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + + - ${{ else }}: + - bash: mkdir -p "$(SCREENSHOTS_DIR)" + displayName: Create Screenshots Directory + - ${{ if and(eq(parameters.os, 'windows'), eq(parameters.arch, 'arm64')) }}: - script: | @echo off @@ -101,19 +119,19 @@ jobs: # Windows - ${{ if eq(parameters.os, 'windows') }}: - - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - script: $(TEST_DIR)/scripts/run-win32.cmd -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # macOS - ${{ if eq(parameters.os, 'macOS') }}: - - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-macOS.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests # Native Linux host - ${{ if and(eq(parameters.container, ''), eq(parameters.os, 'linux')) }}: - - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -v ${{ parameters.args }} + - bash: $(TEST_DIR)/scripts/run-ubuntu.sh -c $(BUILD_COMMIT) -q $(BUILD_QUALITY) -t $(LOG_FILE) -s $(SCREENSHOTS_DIR) -v ${{ parameters.args }} workingDirectory: $(TEST_DIR) displayName: Run Sanity Tests @@ -141,6 +159,7 @@ jobs: --quality "$(BUILD_QUALITY)" \ --commit "$(BUILD_COMMIT)" \ --test-results "/root/results.xml" \ + --screenshots-dir "/root/screenshots" \ --verbose \ ${{ parameters.args }} workingDirectory: $(TEST_DIR) 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/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.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..a09758329cb61 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 @@ -165,11 +159,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 +204,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 +345,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..e016db506862f 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(in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI'), 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: @@ -811,3 +738,43 @@ extends: - template: build/azure-pipelines/product-release.yml@self parameters: VSCODE_RELEASE: ${{ parameters.VSCODE_RELEASE }} + + - ${{ 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-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/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..78461a959eda3 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,54 @@ 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 + + - 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/checksums/electron.txt b/build/checksums/electron.txt index 3df57a48a97d2..5d8343f42a9cd 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 +d70954386008ad2c65d9849bb89955ab3c7dd08763256ae0d91d8604e8894d64 *chromedriver-v39.8.0-darwin-arm64.zip +2f6b654337133c13440aafdaf9e8b15f5ebb244e7d49f20977f03438e9bb8adb *chromedriver-v39.8.0-darwin-x64.zip +ef8681bb6b6af42cdf0e14c9ce188f035e01620781308c06cd3c6b922aaea2e6 *chromedriver-v39.8.0-linux-arm64.zip +c03fea6ac2b743d771407dc5f58809f44d2a885b1830b847957823cac2e7b222 *chromedriver-v39.8.0-linux-armv7l.zip +4bb7c6d9b3a7bfdd89edd0db98e63599ebf6dacdb888d5985bbb73f6153acc0c *chromedriver-v39.8.0-linux-x64.zip +aad1f6f970b5636d637c1c242766fbaa5bebe2707a605a38aadc7b40724b3d11 *chromedriver-v39.8.0-mas-arm64.zip +e89ebebe3a135d3ce40168152a0aabfd055b9fa6b118262a6df18405fd2ea433 *chromedriver-v39.8.0-mas-x64.zip +232e1a0460f6a59056499cccfff3265bf92eae22f20f02f2419e5e49552aaed7 *chromedriver-v39.8.0-win32-arm64.zip +ab92f46cc55da7c719175b50203c734781828389b8b3a1a535204bf0dc7d1296 *chromedriver-v39.8.0-win32-ia32.zip +a40eb521063e4ea6791ed4005815fa8ac259c1febc850246a83a47ce120121ce *chromedriver-v39.8.0-win32-x64.zip +d6a33b4c3c0de845ea23d1e2614c6c6d3bbe35b771bb63ae521c4db11373b021 *electron-api.json +5425323fdb23167870075e944ec6cf3ae383fbe45ad141d08b1d9689030ccd05 *electron-v39.8.0-darwin-arm64-dsym-snapshot.zip +aa32ab00ee58d8827cd53ca561b8c26b7cb7e2ad8cb0801acdda117ee728388e *electron-v39.8.0-darwin-arm64-dsym.zip +f94e589804a3394a4735543b888927be873f8f402899d0debe32a9dc570d6285 *electron-v39.8.0-darwin-arm64-symbols.zip +681d82c2ec6677ff0bf12f5bb1808b5a51dcbf10894bd0298641015119a3e04d *electron-v39.8.0-darwin-arm64.zip +a95e83b5cde762a37e64229e5669b0c19b95aac148689d96ca344535109eb983 *electron-v39.8.0-darwin-x64-dsym-snapshot.zip +8c989d8ca835ecdd93d49d9627f5548272c0ed03e263392b21ed287960b29e41 *electron-v39.8.0-darwin-x64-dsym.zip +b4b6fda9c5b9063a104318645aa29ef4738dd099da2b722e3e9b6dde5e098418 *electron-v39.8.0-darwin-x64-symbols.zip +ec53f2ba79498410323bb96a19ce98741bf28666cc9d83e07d11dadcc5506f38 *electron-v39.8.0-darwin-x64.zip +9141e64f9d4ea7f0e6a43ae364c8232a0dac79ecec44de2d4a0e5d688fbb742c *electron-v39.8.0-linux-arm64-debug.zip +5fac949d5331abaff0643dbcda7cc187e548cd4bf9d198c1ffc361383bfaa79f *electron-v39.8.0-linux-arm64-symbols.zip +c9db883fa671237fbc16256cf89aba55b9fcfbd9825fec32a6d57724a6446fe1 *electron-v39.8.0-linux-arm64.zip +b26ac10e84f6b7d338c13a38547aa66b5e9afbe2f1355b183ebc2ff8f428cfa9 *electron-v39.8.0-linux-armv7l-debug.zip +16c47c008a8783f6c8d6387fe01ea15425161befbf4211e4667bbdd6bb806ef0 *electron-v39.8.0-linux-armv7l-symbols.zip +b1b37fd450a5081a876c2b00b6ca007d454747a7d1d8f04feb16119d6ace94c6 *electron-v39.8.0-linux-armv7l.zip +1e8039cdf60b27785771c9e3f3c4c39fad37602bb0e6b75a30f83c57fdbef069 *electron-v39.8.0-linux-x64-debug.zip +ff9ca169c6e79649dd4c5a49a82a8d4b1761b62fbe14c15c61bf534381a9f653 *electron-v39.8.0-linux-x64-symbols.zip +854076cc4c63d6d6c320df1ca3f4bd7084ef9f9bb47c7b75d80feb2c2ed920b4 *electron-v39.8.0-linux-x64.zip +91bc313cbd009435552d8d5efff5d6ed0ff15465743c2629dac1cfe99ac34e4d *electron-v39.8.0-mas-arm64-dsym-snapshot.zip +974f10f80ec6c65f8d9f2ac1ccd8c4395bb34e24e2b09dc0ff80bd351099692e *electron-v39.8.0-mas-arm64-dsym.zip +b3878bc9198cff324b7c829ce2fbea7a4ee505f2f99b0bb3c11ac5e60651be59 *electron-v39.8.0-mas-arm64-symbols.zip +48dac99c757a850b0db7b38c1b95e08270f690a7ea1b58872e45308c2f7c8c93 *electron-v39.8.0-mas-arm64.zip +1a6e4df1092f89ed46833938d6dd1b3036640037bd09f0630a369ae386a7c872 *electron-v39.8.0-mas-x64-dsym-snapshot.zip +81425eb867527341af64c00726bd462957fec4d5f073922df891d830addbc5bc *electron-v39.8.0-mas-x64-dsym.zip +748ce154e894a27b117b46354cc288dc9442fade844c637b59fe1c1f3f7c625d *electron-v39.8.0-mas-x64-symbols.zip +91f8f7d4eb1a42ac4fa0eaa93034c8e6155ccb50718f9f55541ce2be4a4ed6d0 *electron-v39.8.0-mas-x64.zip +b775b7584afb84e52b0a770e1e63a2f17384b66eeebe845e0c5c82beacaf7e93 *electron-v39.8.0-win32-arm64-pdb.zip +ac62373d11ed682b4fcdae27de2bd72ebf7d46d3b569f5fcf242de01786d0948 *electron-v39.8.0-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-arm64-toolchain-profile.zip +08b79fa5deabbcace447f1e15eb99b3b117b42a84b71ad5b0f52d2da68a34192 *electron-v39.8.0-win32-arm64.zip +f4fb798d76a0c2f80717ef1607571537dbbb07f1cc5f177048bcfd17046c2255 *electron-v39.8.0-win32-ia32-pdb.zip +37c1d2988793604294724b648589fca6459472021189abab1550d5e1eecff1a7 *electron-v39.8.0-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-ia32-toolchain-profile.zip +59b70a12abedb550795614bc74c5803787e824da3529a631fdb5c2b5aad00196 *electron-v39.8.0-win32-ia32.zip +0357c6fb0d7198c45cba0e8c939473ea1d971e1efe801bc84e2c559141b368e7 *electron-v39.8.0-win32-x64-pdb.zip +8e6f4e8516d15aecde5244beac315067c13513c7074383086523eef2638a5e8d *electron-v39.8.0-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-x64-toolchain-profile.zip +9edc111b22aee1a0efb5103d6d3b48645af57b48214eeb48f75f9edfc3e271d6 *electron-v39.8.0-win32-x64.zip +b6eca0e05fcff2464382278dff52367f6f21eb1a580dd8a0a954fc16397ab085 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.0-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.0-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.0-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.0-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.0-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.0-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.0-mas-x64.zip +3ba7c7507181e0d4836f70f3d8800b4e9ba379e1086e9e89fda7ff9b3b9ad2cb *ffmpeg-v39.8.0-win32-arm64.zip +f37e7d51b8403e2ed8ca192bc6ae759cf63d80010e747b15eeb7120b575578b2 *ffmpeg-v39.8.0-win32-ia32.zip +b252e232438010f9683e8fd10c3bf0631df78e42a6ae11d6cb7aa7e6ac11185f *ffmpeg-v39.8.0-win32-x64.zip +365735192f58a7f7660100227ec348ba3df604415ff5264b54d93cb6cf5f6f6f *hunspell_dictionaries.zip +6384ee31daa39de4dd4bd3aa225cdb14cdddb7f463a2c1663b38a79e122a13e2 *libcxx-objects-v39.8.0-linux-arm64.zip +9748b3272e52a8274fe651def2d6ae2dad7a3771b520dd105f46f4020ba9d63b *libcxx-objects-v39.8.0-linux-armv7l.zip +74d47a155ecc6c2054418c7c3e0540f32b983ebdc65e8b4ea5d3e257d29b3f4f *libcxx-objects-v39.8.0-linux-x64.zip +c0755fbb84011664bd36459fc6e06a603078dccd3b7b260f6ed6ad1d409f79f7 *libcxx_headers.zip +3ea41e9bd56e8f52ab8562c1406ba9416abe3993640935e981cbbd77c0f2654b *libcxxabi_headers.zip +befcd6067f35d911a6a87b927e79dc531cb7bea39e85f86a65e9ab82ef0cece1 *mksnapshot-v39.8.0-darwin-arm64.zip +f0e692655298ffed60630c3e6490ced69e9d8726e85bcaecfa34485f3a991469 *mksnapshot-v39.8.0-darwin-x64.zip +d5d0901cd1eafdf921d2a0d1565829cf60f454a71ce74fa60db98780fd8a1a96 *mksnapshot-v39.8.0-linux-arm64-x64.zip +1bc0a3294d258a59846aa5c5359cd8b0f43831ebd7c3e1dde9a6cfaa39d845bf *mksnapshot-v39.8.0-linux-armv7l-x64.zip +4e414dbe75f460cb34508608db984aa6f4d274f333fa327a3d631da4a516da8f *mksnapshot-v39.8.0-linux-x64.zip +c51c86e3a11ad75fb4f7559798f6d64ec7def19583c96ce08de7ee5796568841 *mksnapshot-v39.8.0-mas-arm64.zip +6544d1e93adea1e9a694f9b9f539d96f84df647d9c9319b29d4fc88751ff9075 *mksnapshot-v39.8.0-mas-x64.zip +372b4685c53f19ccc72c33d78c1283d9389c72f42cd48224439fe4f89199caa0 *mksnapshot-v39.8.0-win32-arm64-x64.zip +199e9244f4522a4a02aece09a6a33887b24d7ec837640d39c930170e4b3caa57 *mksnapshot-v39.8.0-win32-ia32.zip +970e979e7a8b70f300f7854cb571756d9049bc42b44a6153a9ce3a18e1a83243 *mksnapshot-v39.8.0-win32-x64.zip diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 26aead0ca19dd..9e90e31491f58 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -28,7 +28,7 @@ async function main(buildDir?: string) { const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', - '**/policies/{*.mobileconfig,**/*.plist}', + '**/policies/{*.mobileconfig,**/*.plist}' ]; await makeUniversalApp({ 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/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..0ad08ab6fbe98 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -83,6 +83,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/**', ]; diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index c50bdfcda3f7c..f60817b66d525 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,6 +31,7 @@ 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 type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; @@ -42,8 +43,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 +89,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 +99,8 @@ const vscodeResourceIncludes = [ // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + 'out-build/vs/sessions/prompts/*.prompt.md', + 'out-build/vs/sessions/skills/**/SKILL.md', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', @@ -236,6 +238,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 +248,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 +272,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 +324,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 +354,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const extensions = gulp.src(['.build/extensions/**', ...platformSpecificBuiltInExtensionsExclusions], { base: '.build', dot: true }); + const sourceFilterPattern = stripSourceMapsInPackagingTasks + ? ['**', '!**/*.{js,css}.map'] + : ['**']; const sources = es.merge(src, extensions) - .pipe(filter(['**', '!**/*.{js,css}.map'], { dot: true })); + .pipe(filter(sourceFilterPattern, { dot: true })); let version = packageJson.version; const quality = (product as { quality?: string }).quality; @@ -389,7 +393,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; const embedded = isInsiderOrExploration - ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string; urlProtocol: string } }).embedded + ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; const packageSubJsonStream = isInsiderOrExploration @@ -404,12 +408,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,8 +428,13 @@ 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(jsFilter) @@ -500,6 +506,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}`)); @@ -519,10 +528,19 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d platform, arch: arch === 'armhf' ? 'arm' : arch, ffmpegChromium: false, + darwinAssetsCar: 'resources/darwin/code.car', ...(embedded ? { darwinMiniAppName: embedded.nameShort, darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, darwinMiniAppIcon: 'resources/darwin/sessions.icns', + darwinMiniAppAssetsCar: 'resources/darwin/sessions.car', + darwinMiniAppBundleURLTypes: [{ + role: 'Viewer', + name: embedded.nameLong, + urlSchemes: [embedded.urlProtocol] + }], + win32ProxyAppName: embedded.nameShort, + win32ProxyIcon: 'resources/win32/sessions.ico', } : {}) }; @@ -531,7 +549,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 +570,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 +603,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 +648,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 }), @@ -692,7 +718,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..0f81323c98db2 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -14,6 +14,7 @@ import product from '../product.json' with { type: 'json' }; import { getVersion } from './lib/getVersion.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; +import type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -112,6 +113,17 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { Quality: quality }; + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration + ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded + : undefined; + + if (embedded) { + definitions['ProxyExeBasename'] = embedded.nameShort; + definitions['ProxyAppUserId'] = embedded.win32AppUserModelId; + definitions['ProxyNameLong'] = embedded.nameLong; + } + if (quality === 'stable' || quality === 'insider') { definitions['AppxPackage'] = `${quality === 'stable' ? 'code' : 'code_insider'}_${arch}.appx`; definitions['AppxPackageDll'] = `${quality === 'stable' ? 'code' : 'code_insider'}_explorer_command_${arch}.dll`; diff --git a/build/lib/date.ts b/build/lib/date.ts index 68d52521349ed..99ba91a5282df 100644 --- a/build/lib/date.ts +++ b/build/lib/date.ts @@ -5,9 +5,23 @@ import path from 'path'; import fs from 'fs'; +import { execSync } from 'child_process'; const root = path.join(import.meta.dirname, '..', '..'); +/** + * Get the ISO date for the build. Uses the git commit date of HEAD + * so that independent builds on different machines produce the same + * timestamp (required for deterministic builds, e.g. macOS Universal). + */ +export function getGitCommitDate(): string { + try { + return execSync('git log -1 --format=%cI HEAD', { cwd: root, encoding: 'utf8' }).trim(); + } catch { + return new Date().toISOString(); + } +} + /** * Writes a `outDir/date` file with the contents of the build * so that other tasks during the build process can use it and @@ -18,7 +32,7 @@ export function writeISODate(outDir: string) { const outDirectory = path.join(root, outDir); fs.mkdirSync(outDirectory, { recursive: true }); - const date = new Date().toISOString(); + const date = getGitCommitDate(); fs.writeFileSync(path.join(outDirectory, 'date'), date, 'utf8'); resolve(); diff --git a/build/lib/embeddedType.ts b/build/lib/embeddedType.ts new file mode 100644 index 0000000000000..4b3075f4a7165 --- /dev/null +++ b/build/lib/embeddedType.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type EmbeddedProductInfo = { + nameShort: string; + nameLong: string; + applicationName: string; + dataFolderName: string; + darwinBundleIdentifier: string; + urlProtocol: string; + win32AppUserModelId: string; + win32MutexName: string; + win32RegValueName: string; + win32NameVersion: string; + win32VersionedUpdate: boolean; +}; diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index 5710f4d6919fd..aacf25cbbc131 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'); @@ -649,70 +513,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..26e1e27302743 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,120 @@ { "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/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/sessions", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/terminal", + "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/welcome", + "project": "vscode-sessions" } ] } diff --git a/build/lib/i18n.ts b/build/lib/i18n.ts index 8ebcb1f177b06..61ed524f35bf8 100644 --- a/build/lib/i18n.ts +++ b/build/lib/i18n.ts @@ -391,7 +391,8 @@ const editorProject: string = 'vscode-editor', workbenchProject: string = 'vscode-workbench', extensionsProject: string = 'vscode-extensions', setupProject: string = 'vscode-setup', - serverProject: string = 'vscode-server'; + serverProject: string = 'vscode-server', + sessionsProject: string = 'vscode-sessions'; export function getResource(sourceFile: string): Resource { let resource: string; @@ -416,6 +417,11 @@ export function getResource(sourceFile: string): Resource { return { name: resource, project: workbenchProject }; } else if (/^vs\/workbench/.test(sourceFile)) { return { name: 'vs/workbench', project: workbenchProject }; + } else if (/^vs\/sessions\/contrib/.test(sourceFile)) { + resource = sourceFile.split('/', 4).join('/'); + return { name: resource, project: sessionsProject }; + } else if (/^vs\/sessions/.test(sourceFile)) { + return { name: 'vs/sessions', project: sessionsProject }; } throw new Error(`Could not identify the XLF bundle for ${sourceFile}`); @@ -737,6 +743,10 @@ export function prepareI18nPackFiles(resultingTranslationPaths: TranslationPath[ if (EXTERNAL_EXTENSIONS.find(e => e === resource)) { project = extensionsProject; } + // vscode-setup has its own import path via prepareIslFiles + if (project === setupProject) { + return; + } const contents = xlf.contents!.toString(); log(`Found ${project}: ${resource}`); const parsePromise = getL10nFilesFromXlf(contents); diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9c1e1e0e87a8f..f58dda70afad4 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -39,28 +39,28 @@ ], "policies": [ { - "key": "chat.mcp.gallery.serviceUrl", - "name": "McpGalleryServiceUrl", - "category": "InteractiveSession", - "minimumVersion": "1.101", + "key": "extensions.gallery.serviceUrl", + "name": "ExtensionGalleryServiceUrl", + "category": "Extensions", + "minimumVersion": "1.99", "localization": { "description": { - "key": "mcp.gallery.serviceUrl", - "value": "Configure the MCP Gallery service URL to connect to" + "key": "extensions.gallery.serviceUrl", + "value": "Configure the Marketplace service URL to connect to" } }, "type": "string", "default": "" }, { - "key": "extensions.gallery.serviceUrl", - "name": "ExtensionGalleryServiceUrl", - "category": "Extensions", - "minimumVersion": "1.99", + "key": "chat.mcp.gallery.serviceUrl", + "name": "McpGalleryServiceUrl", + "category": "InteractiveSession", + "minimumVersion": "1.101", "localization": { "description": { - "key": "extensions.gallery.serviceUrl", - "value": "Configure the Marketplace service URL to connect to" + "key": "mcp.gallery.serviceUrl", + "value": "Configure the MCP Gallery service URL to connect to" } }, "type": "string", @@ -169,6 +169,20 @@ "type": "boolean", "default": true }, + { + "key": "chat.editMode.hidden", + "name": "DeprecatedEditModeHidden", + "category": "InteractiveSession", + "minimumVersion": "1.112", + "localization": { + "description": { + "key": "chat.editMode.hidden", + "value": "When enabled, hides the Edit mode from the chat mode picker." + } + }, + "type": "boolean", + "default": true + }, { "key": "chat.useHooks", "name": "ChatHooks", @@ -286,6 +300,20 @@ }, "type": "boolean", "default": true + }, + { + "key": "workbench.browser.enableChatTools", + "name": "BrowserChatTools", + "category": "InteractiveSession", + "minimumVersion": "1.110", + "localization": { + "description": { + "key": "browser.enableChatTools", + "value": "When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser." + } + }, + "type": "boolean", + "default": false } ] } diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a913a9534fcfc..5b0fc9b6ad5b4 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,8 @@ "--vscode-searchEditor-findMatchBorder", "--vscode-searchEditor-textInputBorder", "--vscode-selection-background", + "--vscode-sessionsUpdateButton-downloadedBackground", + "--vscode-sessionsUpdateButton-downloadingBackground", "--vscode-settings-checkboxBackground", "--vscode-settings-checkboxBorder", "--vscode-settings-checkboxForeground", @@ -945,6 +949,7 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--update-progress", "--reveal-button-size", "--part-background", "--part-border-color", @@ -968,6 +973,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 +1011,7 @@ "--text-link-decoration", "--vscode-action-item-auto-timeout", "--monaco-editor-warning-decoration", + "--animation-angle", "--animation-opacity", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", diff --git a/build/lib/test/i18n.test.ts b/build/lib/test/i18n.test.ts index 7d5bb0433feed..6c9409bcb4a34 100644 --- a/build/lib/test/i18n.test.ts +++ b/build/lib/test/i18n.test.ts @@ -31,7 +31,8 @@ suite('XLF Parser Tests', () => { test('JSON file source path to Transifex resource match', () => { const editorProject: string = 'vscode-editor', - workbenchProject: string = 'vscode-workbench'; + workbenchProject: string = 'vscode-workbench', + sessionsProject: string = 'vscode-sessions'; const platform: i18n.Resource = { name: 'vs/platform', project: editorProject }, editorContrib = { name: 'vs/editor/contrib', project: editorProject }, @@ -40,7 +41,9 @@ suite('XLF Parser Tests', () => { code = { name: 'vs/code', project: workbenchProject }, workbenchParts = { name: 'vs/workbench/contrib/html', project: workbenchProject }, workbenchServices = { name: 'vs/workbench/services/textfile', project: workbenchProject }, - workbench = { name: 'vs/workbench', project: workbenchProject }; + workbench = { name: 'vs/workbench', project: workbenchProject }, + sessionsContrib = { name: 'vs/sessions/contrib/chat', project: sessionsProject }, + sessions = { name: 'vs/sessions', project: sessionsProject }; assert.deepStrictEqual(i18n.getResource('vs/platform/actions/browser/menusExtensionPoint'), platform); assert.deepStrictEqual(i18n.getResource('vs/editor/contrib/clipboard/browser/clipboard'), editorContrib); @@ -50,5 +53,7 @@ suite('XLF Parser Tests', () => { assert.deepStrictEqual(i18n.getResource('vs/workbench/contrib/html/browser/webview'), workbenchParts); assert.deepStrictEqual(i18n.getResource('vs/workbench/services/textfile/node/testFileService'), workbenchServices); assert.deepStrictEqual(i18n.getResource('vs/workbench/browser/parts/panel/panelActions'), workbench); + assert.deepStrictEqual(i18n.getResource('vs/sessions/contrib/chat/browser/chatWidget'), sessionsContrib); + assert.deepStrictEqual(i18n.getResource('vs/sessions/browser/layoutActions'), sessions); }); }); diff --git a/build/lib/util.ts b/build/lib/util.ts index e4d01e143c93b..4203e6e653041 100644 --- a/build/lib/util.ts +++ b/build/lib/util.ts @@ -381,6 +381,12 @@ export function getElectronVersion(): Record { return { electronVersion, msBuildId }; } +export function getVersionedResourcesFolder(platform: string, commit: string): string { + const productJson = JSON.parse(fs.readFileSync(path.join(root, 'product.json'), 'utf8')); + const useVersionedUpdate = platform === 'win32' && productJson.win32VersionedUpdate; + return useVersionedUpdate ? commit.substring(0, 10) : ''; +} + export class VinylStat implements fs.Stats { readonly dev: number; diff --git a/build/next/index.ts b/build/next/index.ts index b0120837efa26..a5bb0796da00d 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 @@ -257,6 +260,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 +279,9 @@ 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 + 'vs/sessions/prompts/*.prompt.md', ]; // Resources for server target (minimal - no UI) @@ -291,6 +303,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 +437,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 +912,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 +947,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 +970,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 +980,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 +1180,7 @@ async function main(): Promise { // Write build date file (used by packaging to embed in product.json) const outDirPath = path.join(REPO_ROOT, outDir); await fs.promises.mkdir(outDirPath, { recursive: true }); - await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); + await fs.promises.writeFile(path.join(outDirPath, 'date'), getGitCommitDate(), 'utf8'); console.log(`[transpile] ${SRC_DIR} → ${outDir}${options.excludeTests ? ' (excluding tests)' : ''}`); const t1 = Date.now(); diff --git a/build/next/nls-plugin.ts b/build/next/nls-plugin.ts index 7be3faccf2439..e2b19f7d7f13d 100644 --- a/build/next/nls-plugin.ts +++ b/build/next/nls-plugin.ts @@ -12,6 +12,7 @@ import { analyzeLocalizeCalls, parseLocalizeKeyOrValue } from '../lib/nls-analysis.ts'; +import type { TextEdit } from './private-to-property.ts'; // ============================================================================ // Types @@ -148,12 +149,13 @@ export async function finalizeNLS( /** * Post-processes a JavaScript file to replace NLS placeholders with indices. + * Returns the transformed code and the edits applied (for source map adjustment). */ export function postProcessNLS( content: string, indexMap: Map, preserveEnglish: boolean -): string { +): { code: string; edits: readonly TextEdit[] } { return replaceInOutput(content, indexMap, preserveEnglish); } @@ -244,7 +246,7 @@ function generateNLSSourceMap( const generator = new SourceMapGenerator(); generator.setSourceContent(filePath, originalSource); - const lineCount = originalSource.split('\n').length; + const lines = originalSource.split('\n'); // Group edits by line const editsByLine = new Map(); @@ -257,7 +259,7 @@ function generateNLSSourceMap( arr.push(edit); } - for (let line = 0; line < lineCount; line++) { + for (let line = 0; line < lines.length; line++) { const smLine = line + 1; // source maps use 1-based lines // Always map start of line @@ -273,7 +275,8 @@ function generateNLSSourceMap( let cumulativeShift = 0; - for (const edit of lineEdits) { + for (let i = 0; i < lineEdits.length; i++) { + const edit = lineEdits[i]; const origLen = edit.endCol - edit.startCol; // Map start of edit: the replacement begins at the same original position @@ -285,12 +288,20 @@ function generateNLSSourceMap( cumulativeShift += edit.newLength - origLen; - // Map content after edit: columns resume with the shift applied - generator.addMapping({ - generated: { line: smLine, column: edit.endCol + cumulativeShift }, - original: { line: smLine, column: edit.endCol }, - source: filePath, - }); + // Source maps don't interpolate columns — each query resolves to the + // last segment with generatedColumn <= queryColumn. A single mapping + // at edit-end would cause every subsequent column on this line to + // collapse to that one original position. Add per-column identity + // mappings from edit-end to the next edit (or end of line) so that + // esbuild's source-map composition preserves fine-grained accuracy. + const nextBound = i + 1 < lineEdits.length ? lineEdits[i + 1].startCol : lines[line].length; + for (let origCol = edit.endCol; origCol < nextBound; origCol++) { + generator.addMapping({ + generated: { line: smLine, column: origCol + cumulativeShift }, + original: { line: smLine, column: origCol }, + source: filePath, + }); + } } } } @@ -302,17 +313,19 @@ function replaceInOutput( content: string, indexMap: Map, preserveEnglish: boolean -): string { - // Replace all placeholders in a single pass using regex - // Two types of placeholders: - // - %%NLS:moduleId#key%% for localize() - message replaced with null - // - %%NLS2:moduleId#key%% for localize2() - message preserved - // Note: esbuild may use single or double quotes, so we handle both +): { code: string; edits: readonly TextEdit[] } { + // Collect all matches first, then apply from back to front so that byte + // offsets remain valid. Each match becomes a TextEdit in terms of the + // ORIGINAL content offsets, which is what adjustSourceMap expects. + + interface PendingEdit { start: number; end: number; replacement: string } + const pending: PendingEdit[] = []; if (preserveEnglish) { - // Just replace the placeholder with the index (both NLS and NLS2) - return content.replace(/["']%%NLS2?:([^%]+)%%["']/g, (match, inner) => { - // Try NLS first, then NLS2 + const re = /["']%%NLS2?:([^%]+)%%["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const inner = m[1]; let placeholder = `%%NLS:${inner}%%`; let index = indexMap.get(placeholder); if (index === undefined) { @@ -320,45 +333,60 @@ function replaceInOutput( index = indexMap.get(placeholder); } if (index !== undefined) { - return String(index); + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - // Placeholder not found in map, leave as-is (shouldn't happen) - return match; - }); + } } else { - // For NLS (localize): replace placeholder with index AND replace message with null - // For NLS2 (localize2): replace placeholder with index, keep message - // Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\ - // Note: esbuild may use single or double quotes, so we handle both - - // First handle NLS (localize) - replace both key and message - content = content.replace( - /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, - (match, inner, comma) => { - const placeholder = `%%NLS:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return `${index}${comma}null`; - } - return match; + // NLS (localize): replace placeholder with index AND replace message with null + const reNLS = /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g; + let m: RegExpExecArray | null; + while ((m = reNLS.exec(content)) !== null) { + const inner = m[1]; + const comma = m[2]; + const placeholder = `%%NLS:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: `${index}${comma}null` }); } - ); - - // Then handle NLS2 (localize2) - replace only key, keep message - content = content.replace( - /["']%%NLS2:([^%]+)%%["']/g, - (match, inner) => { - const placeholder = `%%NLS2:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return String(index); - } - return match; + } + + // NLS2 (localize2): replace only key, keep message + const reNLS2 = /["']%%NLS2:([^%]+)%%["']/g; + while ((m = reNLS2.exec(content)) !== null) { + const inner = m[1]; + const placeholder = `%%NLS2:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - ); + } + } - return content; + if (pending.length === 0) { + return { code: content, edits: [] }; } + + // Sort by offset ascending, then apply back-to-front to keep offsets valid + pending.sort((a, b) => a.start - b.start); + + // Build TextEdit[] (in original-content coordinates) and apply edits + const edits: TextEdit[] = []; + for (const p of pending) { + edits.push({ start: p.start, end: p.end, newText: p.replacement }); + } + + // Apply edits using forward-scanning parts array — O(N+K) instead of + // O(N*K) from repeated substring concatenation on large strings. + const parts: string[] = []; + let lastEnd = 0; + for (const p of pending) { + parts.push(content.substring(lastEnd, p.start)); + parts.push(p.replacement); + lastEnd = p.end; + } + parts.push(content.substring(lastEnd)); + + return { code: parts.join(''), edits }; } // ============================================================================ @@ -399,7 +427,11 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { // back to the original. Embed it inline so esbuild composes it // with its own bundle source map, making the final map point to // the original TS source. - const sourceName = relativePath.replace(/\\/g, '/'); + // This inline source map is resolved relative to esbuild's sourcefile + // for args.path. Using the full repo-relative path here makes esbuild + // resolve it against the file's own directory, which duplicates the + // directory segments in the final bundled source map. + const sourceName = path.basename(args.path); const sourcemap = generateNLSSourceMap(source, sourceName, edits); const encodedMap = Buffer.from(sourcemap).toString('base64'); const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`; diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts index 11f977774a5fd..98ff98a64408a 100644 --- a/build/next/private-to-property.ts +++ b/build/next/private-to-property.ts @@ -220,15 +220,53 @@ export function adjustSourceMap( return sourceMapJson; } - // Build a line-offset table for the original code to convert byte offsets to line/column - const lineStarts: number[] = [0]; - for (let i = 0; i < originalCode.length; i++) { - if (originalCode.charCodeAt(i) === 10 /* \n */) { - lineStarts.push(i + 1); + // Build line-offset tables for the original code and the code after edits. + // When edits span newlines (e.g. NLS replacing a multi-line template literal + // with `null`), subsequent lines shift up and columns change. We handle this + // by converting each mapping's old generated (line, col) to a byte offset, + // adjusting the offset for the edits, then converting back to (line, col) in + // the post-edit coordinate system. + + const oldLineStarts = buildLineStarts(originalCode); + const newLineStarts = buildLineStartsAfterEdits(originalCode, edits); + + // Precompute cumulative byte-shift after each edit for binary search + const n = edits.length; + const editStarts: number[] = new Array(n); + const editEnds: number[] = new Array(n); + const cumShifts: number[] = new Array(n); // cumulative shift *after* edit[i] + let cumShift = 0; + for (let i = 0; i < n; i++) { + editStarts[i] = edits[i].start; + editEnds[i] = edits[i].end; + cumShift += edits[i].newText.length - (edits[i].end - edits[i].start); + cumShifts[i] = cumShift; + } + + function adjustOffset(oldOff: number): number { + // Binary search: find last edit with start <= oldOff + let lo = 0, hi = n - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (editStarts[mid] <= oldOff) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // hi = index of last edit where start <= oldOff, or -1 if none + if (hi < 0) { + return oldOff; } + if (oldOff < editEnds[hi]) { + // Inside edit range — clamp to edit start in new coordinates + const prevShift = hi > 0 ? cumShifts[hi - 1] : 0; + return editStarts[hi] + prevShift; + } + return oldOff + cumShifts[hi]; } - function offsetToLineCol(offset: number): { line: number; col: number } { + function offsetToLineCol(lineStarts: readonly number[], offset: number): { line: number; col: number } { let lo = 0, hi = lineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; @@ -241,23 +279,9 @@ export function adjustSourceMap( return { line: lo, col: offset - lineStarts[lo] }; } - // Convert edits from byte offsets to per-line column shifts - interface LineEdit { col: number; origLen: number; newLen: number } - const editsByLine = new Map(); - for (const edit of edits) { - const pos = offsetToLineCol(edit.start); - const origLen = edit.end - edit.start; - let arr = editsByLine.get(pos.line); - if (!arr) { - arr = []; - editsByLine.set(pos.line, arr); - } - arr.push({ col: pos.col, origLen, newLen: edit.newText.length }); - } - // Use source-map library to read, adjust, and write const consumer = new SourceMapConsumer(sourceMapJson); - const generator = new SourceMapGenerator({ file: sourceMapJson.file }); + const generator = new SourceMapGenerator({ file: sourceMapJson.file, sourceRoot: sourceMapJson.sourceRoot }); // Copy sourcesContent for (let i = 0; i < sourceMapJson.sources.length; i++) { @@ -267,15 +291,19 @@ export function adjustSourceMap( } } - // Walk every mapping, adjust the generated column, and add to the new generator + // Walk every mapping, convert old generated position → byte offset → adjust → new position consumer.eachMapping(mapping => { - const lineEdits = editsByLine.get(mapping.generatedLine - 1); // 0-based for our data - const adjustedCol = adjustColumn(mapping.generatedColumn, lineEdits); + const oldLine0 = mapping.generatedLine - 1; // 0-based + const oldOff = (oldLine0 < oldLineStarts.length + ? oldLineStarts[oldLine0] + : oldLineStarts[oldLineStarts.length - 1]) + mapping.generatedColumn; + + const newOff = adjustOffset(oldOff); + const newPos = offsetToLineCol(newLineStarts, newOff); - // Some mappings may be unmapped (no original position/source) - skip those. if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) { const newMapping: Mapping = { - generated: { line: mapping.generatedLine, column: adjustedCol }, + generated: { line: newPos.line + 1, column: newPos.col }, original: { line: mapping.originalLine, column: mapping.originalColumn }, source: mapping.source, }; @@ -283,25 +311,82 @@ export function adjustSourceMap( newMapping.name = mapping.name; } generator.addMapping(newMapping); + } else { + // Preserve unmapped segments (generated-only mappings with no original + // position). These create essential "gaps" that prevent + // originalPositionFor() from wrongly interpolating between distant + // valid mappings on the same line in minified output. + // eslint-disable-next-line local/code-no-dangerous-type-assertions + generator.addMapping({ + generated: { line: newPos.line + 1, column: newPos.col }, + } as Mapping); } }); return JSON.parse(generator.toString()); } -function adjustColumn(col: number, lineEdits: { col: number; origLen: number; newLen: number }[] | undefined): number { - if (!lineEdits) { - return col; +function buildLineStarts(text: string): number[] { + const starts: number[] = [0]; + let pos = 0; + while (true) { + const nl = text.indexOf('\n', pos); + if (nl === -1) { + break; + } + starts.push(nl + 1); + pos = nl + 1; + } + return starts; +} + +/** + * Compute line starts for the code that results from applying `edits` to + * `originalCode`, without materialising the full new string. + */ +function buildLineStartsAfterEdits(originalCode: string, edits: readonly TextEdit[]): number[] { + const starts: number[] = [0]; + let oldPos = 0; + let newPos = 0; + + for (const edit of edits) { + // Scan unchanged region [oldPos, edit.start) for newlines + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1 || nl >= edit.start) { + break; + } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; + } + newPos += edit.start - oldPos; + + // Scan replacement text for newlines + let replFrom = 0; + while (true) { + const nl = edit.newText.indexOf('\n', replFrom); + if (nl === -1) { + break; + } + starts.push(newPos + nl + 1); + replFrom = nl + 1; + } + newPos += edit.newText.length; + + oldPos = edit.end; } - let shift = 0; - for (const edit of lineEdits) { - if (edit.col + edit.origLen <= col) { - shift += edit.newLen - edit.origLen; - } else if (edit.col < col) { - return edit.col + shift; - } else { + + // Scan remaining unchanged text after last edit + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1) { break; } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; } - return col + shift; + + return starts; } diff --git a/build/next/test/nls-sourcemap.test.ts b/build/next/test/nls-sourcemap.test.ts index fd732b8680217..c3aad2c80dcdc 100644 --- a/build/next/test/nls-sourcemap.test.ts +++ b/build/next/test/nls-sourcemap.test.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { type RawSourceMap, SourceMapConsumer } from 'source-map'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts'; +import { adjustSourceMap } from '../private-to-property.ts'; // analyzeLocalizeCalls requires the import path to end with `/nls` const NLS_STUB = [ @@ -36,7 +37,7 @@ interface BundleResult { async function bundleWithNLS( files: Record, entryPoint: string, - opts?: { postProcess?: boolean } + opts?: { postProcess?: boolean; minify?: boolean } ): Promise { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-')); const srcDir = path.join(tmpDir, 'src'); @@ -64,6 +65,7 @@ async function bundleWithNLS( packages: 'external', sourcemap: 'linked', sourcesContent: true, + minify: opts?.minify ?? false, write: false, plugins: [ nlsPlugin({ baseDir: srcDir, collector }), @@ -91,7 +93,16 @@ async function bundleWithNLS( // Optionally apply NLS post-processing (replaces placeholders with indices) if (opts?.postProcess) { const nlsResult = await finalizeNLS(collector, outDir); - jsContent = postProcessNLS(jsContent, nlsResult.indexMap, false); + const preNLSCode = jsContent; + const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false); + jsContent = nlsProcessed.code; + + // Adjust source map for NLS edits + if (nlsProcessed.edits.length > 0) { + const mapJson = JSON.parse(mapContent); + const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits); + mapContent = JSON.stringify(adjusted); + } } assert.ok(jsContent, 'Expected JS output'); @@ -209,6 +220,28 @@ suite('NLS plugin source maps', () => { } }); + test('NLS-affected nested file keeps a non-duplicated source path', async () => { + const source = [ + 'import { localize } from "../../vs/nls";', + 'export const msg = localize("myKey", "Hello World");', + ].join('\n'); + + const { mapJson, cleanup } = await bundleWithNLS( + { 'nested/deep/file.ts': source }, + 'nested/deep/file.ts', + ); + + try { + const sources: string[] = mapJson.sources ?? []; + const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts')); + assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources'); + assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'), + `Source path should not duplicate directory segments. Actual: ${nestedSource}`); + } finally { + cleanup(); + } + }); + test('line mapping correct for code after localize calls', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 @@ -370,4 +403,82 @@ suite('NLS plugin source maps', () => { cleanup(); } }); + + test('post-processed NLS - column mappings correct after placeholder replacement', async () => { + // NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their + // replacements (e.g. "0"). Without source map adjustment the columns for + // tokens AFTER the replacement drift by the cumulative length delta. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + 'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/drift.ts': source }, + 'test/drift.ts', + { postProcess: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + const bundleLine = findLine(js, 'FINDME'); + const bundleCol = findColumn(js, '"FINDME"'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 2, 'Should map to line 2'); + + const originalCol = findColumn(source, '"FINDME"'); + const columnDrift = Math.abs(pos.column! - originalCol); + assert.ok(columnDrift <= 20, + `Column drift after NLS post-processing should be small. ` + + `Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + + `Large drift means postProcessNLS edits were not applied to the source map.`); + } finally { + cleanup(); + } + }); + + test('minified bundle with NLS - end-to-end column mapping', async () => { + // With minification, the entire output is (roughly) on one line. + // Multiple NLS replacements compound their column shifts. A function + // defined after several localize() calls must still map correctly. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + '', // 2 + 'export const a = localize("k1", "Alpha message");', // 3 + 'export const b = localize("k2", "Bravo message that is quite long");', // 4 + 'export const c = localize("k3", "Charlie");', // 5 + 'export const d = localize("k4", "Delta is the fourth letter");', // 6 + '', // 7 + 'export function computeResult(x: number): number {', // 8 + '\treturn x * 42;', // 9 + '}', // 10 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/minified.ts': source }, + 'test/minified.ts', + { postProcess: true, minify: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + // Find the computeResult function in the minified output. + // esbuild minifies `x * 42` and may rename the parameter, so + // search for `*42` which survives both minification and renaming. + const needle = '*42'; + const bundleLine = findLine(js, needle); + const bundleCol = findColumn(js, needle); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source for minified mapping'); + assert.strictEqual(pos.line, 9, + `Should map "*42" back to line 9. Got line ${pos.line}.`); + } finally { + cleanup(); + } + }); }); diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts index aa9da72ce9a51..9b97679767933 100644 --- a/build/next/test/private-to-property.test.ts +++ b/build/next/test/private-to-property.test.ts @@ -439,6 +439,41 @@ suite('adjustSourceMap', () => { assert.strictEqual(pos.column, origGetValueCol, 'getValue column should match original'); }); + test('multi-line edit: removing newlines shifts subsequent lines up', () => { + // Simulates the NLS scenario: a template literal with embedded newlines + // is replaced with `null`, collapsing 3 lines into 1. + const code = [ + 'var a = "hello";', // line 0 (0-based) + 'var b = `line1', // line 1 + 'line2', // line 2 + 'line3`;', // line 3 + 'var c = "world";', // line 4 + ].join('\n'); + const map = createIdentitySourceMap(code, 'test.js'); + + // Replace the template literal `line1\nline2\nline3` with `null` + // (keeps `var b = ` and `;` intact) + const tplStart = code.indexOf('`line1'); + const tplEnd = code.indexOf('line3`') + 'line3`'.length; + const edits = [{ start: tplStart, end: tplEnd, newText: 'null' }]; + + const result = adjustSourceMap(map, code, edits); + const consumer = new SourceMapConsumer(result); + + // After edit, code is: + // "var a = \"hello\";\nvar b = null;\nvar c = \"world\";" + // "var c" was on line 5 (1-based), now on line 3 (1-based) since 2 newlines removed + + // 'var c' at original line 5, col 0 should now map at generated line 3 + const pos = consumer.originalPositionFor({ line: 3, column: 0 }); + assert.strictEqual(pos.line, 5, 'var c should map to original line 5'); + assert.strictEqual(pos.column, 0, 'var c column should be 0'); + + // 'var a' on line 1 should be unaffected + const posA = consumer.originalPositionFor({ line: 1, column: 0 }); + assert.strictEqual(posA.line, 1, 'var a should still map to original line 1'); + }); + test('brand check: #field in obj -> string replacement adjusts map', () => { const code = 'class C { #x; check(o) { return #x in o; } }'; const map = createIdentitySourceMap(code, 'test.js'); diff --git a/build/next/working.md b/build/next/working.md index b59b347611dbd..a7ea64db8b648 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -37,7 +37,7 @@ In [gulpfile.vscode.ts](../gulpfile.vscode.ts#L228-L242), the `core-ci` task use - `runEsbuildTranspile()` → transpile command - `runEsbuildBundle()` → bundle command -Old gulp-based bundling renamed to `core-ci-OLD`. +Old gulp-based bundling renamed to `core-ci-old`. --- @@ -134,7 +134,7 @@ npm run gulp vscode-reh-web-darwin-arm64-min 1. **`BUILD_INSERT_PACKAGE_CONFIGURATION`** - Server bootstrap files ([bootstrap-meta.ts](../../src/bootstrap-meta.ts)) have this marker for package.json injection. Currently handled by [inlineMeta.ts](../lib/inlineMeta.ts) in the old build's packaging step. -2. **Mangling** - The new build doesn't do TypeScript-based mangling yet. Old `core-ci` with mangling is now `core-ci-OLD`. +2. **Mangling** - The new build doesn't do TypeScript-based mangling yet. Old `core-ci` with mangling is now `core-ci-old`. 3. **Entry point duplication** - Entry points are duplicated between [buildfile.ts](../buildfile.ts) and [index.ts](index.ts). Consider consolidating. @@ -222,13 +222,13 @@ Two categories of corruption: 2. **`--source-map-base-url` option** - Rewrites `sourceMappingURL` comments to point to CDN URLs. -3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler now generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. Tests in `test/nls-sourcemap.test.ts`. +3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. `generateNLSSourceMap` adds per-column identity mappings after each edit on a line so that esbuild's source-map composition preserves fine-grained column accuracy (source maps don't interpolate columns — they use binary search, so a single boundary mapping would collapse all subsequent columns to the edit-end position). Tests in `test/nls-sourcemap.test.ts`. 4. **`convertPrivateFields` source map adjustment** (`private-to-property.ts`) - `convertPrivateFields` returns its sorted edits as `TextEdit[]`. `adjustSourceMap()` uses `SourceMapConsumer` to walk every mapping, adjusts generated columns based on cumulative edit shifts per line, and rebuilds with `SourceMapGenerator`. The post-processing loop in `index.ts` saves pre-mangle content + edits per JS file, then applies `adjustSourceMap` to the corresponding `.map`. Tests in `test/private-to-property.test.ts`. -### Not Yet Fixed +5. **`postProcessNLS` source map adjustment** (`nls-plugin.ts`, `index.ts`) — `postProcessNLS` now returns `{ code, edits }` where `edits` is a `TextEdit[]` tracking each replacement's byte offset. The bundle loop in `index.ts` chains `adjustSourceMap` calls: first for mangle edits, then for NLS edits, so both transforms are accurately reflected in the final `.map` file. Tests in `test/nls-sourcemap.test.ts`. -**`postProcessNLS` column drift** - Replaces NLS placeholders with short indices in bundled output without updating `.map` files. Shifts columns but never lines, so line-level debugging and crash reporting work correctly. Fixing would require tracking replacement offsets through regex matches and adjusting the source map, similar to `adjustSourceMap`. +6. **`adjustSourceMap` unmapped segment preservation** (`private-to-property.ts`) — Previously, `adjustSourceMap()` silently dropped mappings where `source === null`. These unmapped segments create essential "gaps" that prevent `originalPositionFor()` from wrongly interpolating between distant valid mappings on the same minified line. Now emits them as generated-only mappings. Also preserves `sourceRoot` from the input map. ### Key Technical Details @@ -241,6 +241,71 @@ Two categories of corruption: **Plugin interaction:** Both the NLS plugin and `fileContentMapperPlugin` register `onLoad({ filter: /\.ts$/ })`. In esbuild, the first `onLoad` to return non-`undefined` wins. The NLS plugin is `unshift`ed (runs first), so files with NLS calls skip `fileContentMapperPlugin`. This is safe in practice since `product.ts` (which has `BUILD->INSERT_PRODUCT_CONFIGURATION`) has no localize calls. +### Still Broken — Full Production Build (`npm run gulp vscode-min`) + +**Symptom:** Source maps are totally broken in the minified production build. E.g. a breakpoint at `src/vs/editor/browser/editorExtensions.ts` line 308 resolves to `src/vs/editor/common/cursor/cursorMoveCommands.ts` line 732 — a completely different file. This is **cross-file** mapping corruption, not just column drift. + +**Status of unit tests:** The fixes above pass in isolated unit tests (small 1–2 file bundles via `esbuild.build` with `minify: true`). The tests verify column drift ≤ 20 and correct line mapping for single-file bundles with NLS. **183 tests pass, 0 failing.** But the full production build bundles hundreds of files into huge minified outputs (e.g. `workbench.desktop.main.js` at ~15 MB) and the source maps break at that scale. + +**Suspected root causes (need investigation):** + +1. **`generateNLSSourceMap` per-column identity mappings may overwhelm esbuild's source-map composition.** The fix added one mapping per column from edit-end to end-of-line (or next edit). For a long TypeScript line with a `localize()` call near the beginning, this generates hundreds of identity mappings per line. Across hundreds of files, the inline source maps embedded in `onLoad` responses may be extremely large. esbuild must compose these with its own source maps during bundling — it may hit limits, silently drop mappings, or produce incorrect composed maps at this scale. **Mitigation to try:** Instead of per-column mappings, use sparser "checkpoint" mappings (e.g., every N characters) or rely only on boundary mappings and accept some column drift within the NLS-transformed region. The old boundary-only approach was wrong (collapsed all downstream columns), but per-column may be the other extreme. + +2. **`adjustSourceMap` may corrupt source indices in large minified bundles.** In a minified bundle, the entire output is on one or very few lines. `adjustSourceMap()` walks every mapping via `SourceMapConsumer.eachMapping()` and adjusts `generatedColumn` using `adjustColumn()`. But when thousands of mappings all share `generatedLine: 1` and there are hundreds of NLS edits on that same line, there may be sorting/ordering bugs: `eachMapping()` returns mappings in generated order by default, but `adjustColumn()` binary-searches through edits sorted by column. If edits cover regions that interleave with mappings from different source files, the cumulative shift calculation might produce wrong columns that then resolve to wrong source files. + +3. **Chained `adjustSourceMap` calls (mangle → NLS) may compound errors.** After the first `adjustSourceMap` for mangle edits, the source map's generated columns are updated. The second call for NLS edits uses `nlsEdits` which were computed against `preNLSCode` — but `preNLSCode` is the post-mangle JS, which is what the first `adjustSourceMap` maps from. This chaining _should_ be correct, but needs verification at scale with a real minified bundle. + +4. **The `source-map` v0.6.1 library may have precision issues with very large VLQ-encoded maps.** The bundled outputs have source maps with hundreds of thousands of mappings. The library is old (2017) and there may be numerical precision or sorting issues with very large maps. Consider testing with `source-map` v0.7+ or the Rust-based `@aspect-build/source-map`. + +5. **Alternative approach: skip per-column NLS plugin mappings, fix only `postProcessNLS`.** The NLS plugin `onLoad` replaces `"key"` with `"%%NLS:longPlaceholder%%"` — a length change that only affects columns on affected lines. The subsequent `postProcessNLS` then replaces the long placeholder with a short index. If the `adjustSourceMap` for `postProcessNLS` is correct, it should compensate for both expansions (plugin expansion + post-process contraction). We might not need per-column mappings in `generateNLSSourceMap` at all — just the boundary mapping. The column will drift in the intermediate representation but `adjustSourceMap` for NLS should fix it. **This hypothesis needs testing.** + +6. **Alternative approach: do NLS replacement purely in post-processing.** Skip the `onLoad` two-phase approach (placeholder insertion + post-processing replacement) entirely. Instead, run `postProcessNLS` as a single post-processing step that directly replaces `localize("key", "message")` → `localize(0, null)` in the bundled JS output, with proper source-map adjustment via `adjustSourceMap`. This avoids both the inline source map composition complexity and the two-step replacement. The downside is that post-processing must parse/regex-match real `localize()` calls (not easy placeholders), which is more fragile. + +**Summary of fixes applied vs status:** + +| Bug | Fix | Unit test | Production | +|-----|-----|-----------|------------| +| `generateNLSSourceMap` only had boundary mappings → columns collapsed | Added per-column identity mappings after each edit | Pass (drift: 0) | **Broken** — may overwhelm esbuild composition at scale | +| `postProcessNLS` didn't track edits for source map adjustment | Returns `{ code, edits }`, chained in `index.ts` | Pass | **Broken** — `adjustSourceMap` may corrupt source indices on huge single-line minified output | +| `adjustSourceMap` dropped unmapped segments | Preserves generated-only mappings + `sourceRoot` | Pass (no regressions) | **Broken** — same cross-file mapping issue | + +**Files involved:** +- `build/next/nls-plugin.ts` — `generateNLSSourceMap()` (per-column mappings), `postProcessNLS()` (returns edits), `replaceInOutput()` (regex replacement) +- `build/next/private-to-property.ts` — `adjustSourceMap()` (column adjustment) +- `build/next/index.ts` — bundle post-processing loop (lines ~899–975), chains adjustSourceMap calls +- `build/next/test/nls-sourcemap.test.ts` — unit tests (pass but don't cover production-scale bundles) + +**How to reproduce:** +```bash +npm run gulp vscode-min +# Open out-vscode-min/ in a debugger, set breakpoints in editor files +# Observe breakpoints resolve to wrong files +``` + +**How to debug further:** +```bash +# 1. Build with just --nls (no mangle) to isolate NLS from mangle issues +npx tsx build/next/index.ts bundle --nls --minify --target desktop --out out-debug + +# 2. Build with just --mangle-privates (no NLS) to isolate mangle issues +npx tsx build/next/index.ts bundle --mangle-privates --minify --target desktop --out out-debug + +# 3. Build with neither (baseline — does esbuild's own map work?) +npx tsx build/next/index.ts bundle --minify --target desktop --out out-debug + +# 4. Compare .map files across the three builds to find where mappings diverge + +# 5. Validate a specific mapping in the large bundle: +node -e " +const {SourceMapConsumer} = require('source-map'); +const fs = require('fs'); +const map = JSON.parse(fs.readFileSync('./out-debug/vs/workbench/workbench.desktop.main.js.map','utf8')); +const c = new SourceMapConsumer(map); +// Look up a known position and see which source file it resolves to +console.log(c.originalPositionFor({line: 1, column: XXXX})); +" +``` + --- ## Self-hosting Setup diff --git a/build/npm/dirs.ts b/build/npm/dirs.ts index 48d76e2731a6e..b56884af25c52 100644 --- a/build/npm/dirs.ts +++ b/build/npm/dirs.ts @@ -60,6 +60,7 @@ export const dirs = [ 'test/mcp', '.vscode/extensions/vscode-selfhost-import-aid', '.vscode/extensions/vscode-selfhost-test-provider', + '.vscode/extensions/vscode-extras', ]; if (existsSync(`${import.meta.dirname}/../../.build/distro/npm`)) { diff --git a/build/npm/fast-install.ts b/build/npm/fast-install.ts new file mode 100644 index 0000000000000..ff9a7d2097cf2 --- /dev/null +++ b/build/npm/fast-install.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as child_process from 'child_process'; +import { root, isUpToDate, forceInstallMessage } from './installStateHash.ts'; + +if (!process.argv.includes('--force') && isUpToDate()) { + console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`); + process.exit(0); +} + +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const result = child_process.spawnSync(npm, ['install'], { + cwd: root, + stdio: 'inherit', + shell: true, + env: { ...process.env, VSCODE_FORCE_INSTALL: '1' }, +}); + +process.exit(result.status ?? 1); diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index 6e28e550f4699..8c499c6740fa9 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -526,13 +526,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts new file mode 100644 index 0000000000000..f52c0a4696d4f --- /dev/null +++ b/build/npm/installStateHash.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * 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(): 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: 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]) { + if (process.argv[2] === '--normalize-file') { + const filePath = process.argv[3]; + if (!filePath) { + process.exit(1); + } + process.stdout.write(normalizeFileContent(filePath)); + } else { + console.log(JSON.stringify({ + root, + stateContentsFile, + current: computeState(), + saved: readSavedState(), + files: [...collectInputFiles(), stateFile], + })); + } +} diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index b6a934f74b3eb..ae2651cd188a1 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,115 @@ function clearInheritedNpmrcConfig(dir: string, env: NodeJS.ProcessEnv): void { } } -for (const dir of dirs) { +async function runWithConcurrency(tasks: (() => Promise)[], concurrency: number): Promise { + const errors: Error[] = []; + let index = 0; - if (dir === '') { - removeParcelWatcherPrebuild(dir); - continue; // already executed in root + async function worker() { + while (index < tasks.length) { + const i = index++; + try { + await tasks[i](); + } catch (err) { + errors.push(err as Error); + } + } } - let opts: child_process.SpawnSyncOptions | undefined; + await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())); - 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']; + if (errors.length > 0) { + for (const err of errors) { + console.error(err.message); } - if (process.env['VSCODE_REMOTE_CXX']) { - opts.env!['CXX'] = process.env['VSCODE_REMOTE_CXX']; - } else { - delete opts.env!['CXX']; + 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 (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 (/^(.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; } - 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 }); + + 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())); } -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..dd53ff4467123 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 @@ -41,6 +42,13 @@ if (process.env.npm_execpath?.includes('yarn')) { throw new Error(); } +// Fast path: if nothing changed since last successful install, skip everything. +// This makes `npm i` near-instant when dependencies haven't changed. +if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) { + console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`); + process.exit(0); +} + if (process.platform === 'win32') { if (!hasSupportedVisualStudioVersion()) { console.error('\x1b[1;31m*** Invalid C/C++ Compiler Toolchain. Please check https://github.com/microsoft/vscode/wiki/How-to-Contribute#prerequisites.\x1b[0;0m'); diff --git a/build/package-lock.json b/build/package-lock.json index b78c4c8389ac5..644e16f901b77 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -48,7 +48,7 @@ "@types/workerpool": "^6.4.0", "@types/xml2js": "0.0.33", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/ripgrep": "^1.15.13", + "@vscode/ripgrep": "^1.17.1", "@vscode/vsce": "3.6.1", "ansi-colors": "^3.2.3", "byline": "^5.0.0", @@ -1027,29 +1027,6 @@ "node": ">=18" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1950,9 +1927,9 @@ "license": "MIT" }, "node_modules/@vscode/ripgrep": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", - "integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.1.tgz", + "integrity": "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2170,6 +2147,29 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@vscode/vsce/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2232,16 +2232,16 @@ } }, "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2318,9 +2318,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -3493,10 +3493,23 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "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.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "dev": true, "funding": [ { @@ -3506,6 +3519,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { @@ -4512,9 +4526,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 +4668,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" }, @@ -6743,12 +6758,13 @@ } }, "node_modules/vscode-universal-bundler/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" diff --git a/build/package.json b/build/package.json index 785f04f3b22e3..8a65120c4d60e 100644 --- a/build/package.json +++ b/build/package.json @@ -42,7 +42,7 @@ "@types/workerpool": "^6.4.0", "@types/xml2js": "0.0.33", "@vscode/iconv-lite-umd": "0.7.1", - "@vscode/ripgrep": "^1.15.13", + "@vscode/ripgrep": "^1.17.1", "@vscode/vsce": "3.6.1", "ansi-colors": "^3.2.3", "byline": "^5.0.0", diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 4179138e714c7..72f036bc0dfbd 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,13 +8,166 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-24", + "@vscode/component-explorer-vite-plugin": "^0.1.1-24", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" } }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-2.2.0.tgz", + "integrity": "sha512-9UAZqn8ywdR70n3GwVle4N8ALosQs4z50N7XMXrSTUVOmVpaBC5kE3TRTT7qQdi3OaQV24mjGuJZsHUmhD+ZXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^1.0.3", + "@octokit/graphql": "^4.3.1", + "@octokit/rest": "^16.43.1" + } + }, + "node_modules/@actions/http-client": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz", + "integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -49,6 +202,65 @@ "tslib": "^2.4.0" } }, + "node_modules/@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@hediet/semver/-/semver-0.2.2.tgz", + "integrity": "sha512-sdH+TwXwaYOgnKij3QQbJERl2HkJ+l8idWINwHBI+8nXl1yuTCMerDLDPC48t1wbr849qBTpJTV1EJXlh7OGAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.0.4", + "@actions/github": "^2.2.0", + "@typescript-eslint/eslint-plugin": "^3.0.1", + "@typescript-eslint/parser": "^3.0.1", + "eslint": "^7.1.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -66,6 +278,322 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@octokit/core/node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/core/node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@octokit/core/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz", + "integrity": "sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz", + "integrity": "sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1", + "deprecation": "^2.3.1" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/rest": { + "version": "16.43.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.2.tgz", + "integrity": "sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^2.4.0", + "@octokit/plugin-paginate-rest": "^1.1.1", + "@octokit/plugin-request-log": "^1.0.0", + "@octokit/plugin-rest-endpoint-methods": "2.4.0", + "@octokit/request": "^5.2.0", + "@octokit/request-error": "^1.0.2", + "atob-lite": "^2.0.0", + "before-after-hook": "^2.0.0", + "btoa-lite": "^1.0.0", + "deprecation": "^2.0.0", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.uniq": "^4.5.0", + "octokit-pagination-methods": "^1.1.0", + "once": "^1.4.0", + "universal-user-agent": "^4.0.0" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/request-error": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz", + "integrity": "sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@octokit/rest/node_modules/universal-user-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz", + "integrity": "sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==", + "dev": true, + "license": "ISC", + "dependencies": { + "os-name": "^3.1.0" + } + }, + "node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, "node_modules/@oxc-project/runtime": { "version": "0.101.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.101.0.tgz", @@ -315,9 +843,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -329,9 +857,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -343,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -357,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -371,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -385,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -399,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -413,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -427,9 +955,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -441,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -455,9 +983,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -469,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -483,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -497,9 +1025,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -511,9 +1039,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -525,9 +1053,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -539,9 +1067,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -553,9 +1081,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -567,9 +1095,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -581,9 +1109,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -595,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -609,9 +1137,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -623,9 +1151,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -637,9 +1165,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -651,9 +1179,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -675,6 +1203,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -682,82 +1217,1131 @@ "dev": true, "license": "MIT" }, - "node_modules/@vscode/component-explorer": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-12.tgz", - "integrity": "sha512-qqbxbu3BvqWtwFdVsROLUSd1BiScCiUPP5n0sk0yV1WDATlAl6wQMX1QlmsZy3hag8iP/MXUEj5tSBjA1T7tFw==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "dev": true, + "license": "MIT", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "undici-types": "~7.18.0" } }, - "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-12.tgz", - "integrity": "sha512-MG5ndoooX2X9PYto1WkNSwWKKmR5OJx3cBnUf7JHm8ERw+8RsZbLe+WS+hVOqnCVPxHy7t+0IYRFl7IC5cuwOQ==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz", + "integrity": "sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ==", "dev": true, + "license": "MIT", "dependencies": { - "tinyglobby": "^0.2.0" + "@typescript-eslint/experimental-utils": "3.10.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@vscode/component-explorer": "*", - "vite": "*" + "@typescript-eslint/parser": "^3.0.0", + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@vscode/rollup-plugin-esm-url": { - "version": "1.0.1-1", - "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-1.tgz", + "node_modules/@typescript-eslint/experimental-utils": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", + "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz", + "integrity": "sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "3.10.1", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", + "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", + "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/visitor-keys": "3.10.1", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", + "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vscode/component-explorer": { + "version": "0.1.1-24", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-24.tgz", + "integrity": "sha512-o+uFX1bqD6dvAALx+Y32Gf7FmQehPsjGAI1Bm+5PvaV/++RIqsniM+VXIwqwjtuUvOyAMOz2TEOPYy3Uju//Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hediet/semver": "^0.2.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@vscode/component-explorer-vite-plugin": { + "version": "0.1.1-24", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-24.tgz", + "integrity": "sha512-XHccBmg4mnIHahBTmoIBaJwvDZM0QOIbDm/qxZAw8Zr1xSfTCRQNBwBAYNrOZe4/XK52N5DLMBmjpFroEtY2WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hediet/semver": "^0.2.2", + "tinyglobby": "^0.2.0" + }, + "peerDependencies": { + "@vscode/component-explorer": "*", + "vite": "*" + } + }, + "node_modules/@vscode/rollup-plugin-esm-url": { + "version": "1.0.1-1", + "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-1.tgz", "integrity": "sha512-vNmIR3ZyiwACUi8qnXhKNukoXaFkOM9skiqVOVHNKJTBb7kJS+evtyadrBc/fMm1y303WQWBNA90E7fCCsE2Sw==", "dev": true, "license": "MIT", - "peerDependencies": { - "rollup": "^3.0.0 || ^4.0.0" + "peerDependencies": { + "rollup": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/atob-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", + "integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=6" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -765,6 +2349,73 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-with-bigint": { + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz", + "integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -1026,6 +2677,49 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1033,29 +2727,212 @@ "dev": true, "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/macos-release": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", + "integrity": "sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/octokit-pagination-methods": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz", + "integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/os-name": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", + "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "macos-release": "^2.2.0", + "windows-release": "^3.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" }, - "bin": { - "loose-envify": "cli.js" + "engines": { + "node": ">=6" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/picocolors": { @@ -1107,6 +2984,47 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1134,6 +3052,56 @@ "react": "^18.3.1" } }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.53.tgz", @@ -1167,9 +3135,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1183,31 +3151,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -1221,6 +3189,67 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1231,6 +3260,125 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1248,6 +3396,13 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1256,6 +3411,111 @@ "license": "0BSD", "optional": true }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-compile-cache": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", + "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "name": "rolldown-vite", "version": "7.3.1", @@ -1332,6 +3592,73 @@ "optional": true } } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/windows-release": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.3.3.tgz", + "integrity": "sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" } } } diff --git a/build/vite/package.json b/build/vite/package.json index 245bf4fc8001a..05a96fa1ffd84 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.1.1-12", - "@vscode/component-explorer-vite-plugin": "^0.1.1-12", + "@vscode/component-explorer": "^0.1.1-24", + "@vscode/component-explorer-vite-plugin": "^0.1.1-24", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/build/vite/vite.config.ts b/build/vite/vite.config.ts index cdae205f030df..24aaa12c02655 100644 --- a/build/vite/vite.config.ts +++ b/build/vite/vite.config.ts @@ -143,11 +143,8 @@ const logger = createLogger(); const loggerWarn = logger.warn; logger.warn = (msg, options) => { - // amdX and the baseUrl code cannot be analyzed by vite. - // However, they are not needed, so it is okay to silence the warning. - if (msg.indexOf('vs/amdX.ts') !== -1) { - return; - } + // the baseUrl code cannot be analyzed by vite. + // However, it is not needed, so it is okay to silence the warning. if (msg.indexOf('await import(new URL(`vs/workbench/workbench.desktop.main.js`, baseUrl).href)') !== -1) { return; } diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index d35c41e4098f9..bc102802568e8 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -3,93 +3,141 @@ version = 4 [[package]] -name = "bitflags" -version = "1.3.2" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc" -version = "3.0.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crossbeam-channel" -version = "0.5.5" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.10" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" -dependencies = [ - "cfg-if", - "once_cell", -] +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "dirs-next" -version = "2.0.0" +name = "deranged" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ - "cfg-if", - "dirs-sys-next", + "powerfmt", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" dependencies = [ - "libc", - "redox_users", - "winapi", + "serde", ] [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -98,38 +146,103 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "getrandom" -version = "0.2.7" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "getrandom" -version = "0.3.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] [[package]] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" dependencies = [ "byteorder", "crc", @@ -142,40 +255,74 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "itoa" -version = "1.0.2" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "num_threads" -version = "0.1.6" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "libc", + "autocfg", ] [[package]] @@ -184,20 +331,36 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" -version = "1.0.40" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.20" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -209,55 +372,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "redox_syscall" -version = "0.2.13" +name = "rustix" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 1.3.2", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] -name = "redox_users" -version = "0.4.3" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ - "getrandom 0.2.7", - "redox_syscall", - "thiserror", + "serde_core", ] [[package]] -name = "rustix" -version = "1.0.7" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", + "serde_derive", ] [[package]] -name = "rustversion" -version = "1.0.7" +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slog" -version = "2.7.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] [[package]] name = "slog-async" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766c59b252e62a34651412870ff55d8c4e6d04df19b43eecb2703e417b097ffe" +checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84" dependencies = [ "crossbeam-channel", "slog", @@ -267,10 +470,11 @@ dependencies = [ [[package]] name = "slog-term" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6e022d0b998abfe5c3782c1f03551a596269450ccd677ea51c56f8b214610e8" +checksum = "5cb1fc680b38eed6fad4c02b3871c09d2c81db8c96aa4e9c0a34904c830f09b5" dependencies = [ + "chrono", "is-terminal", "slog", "term", @@ -280,9 +484,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.98" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -297,156 +501,256 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "term" -version = "0.7.0" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ - "dirs-next", + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", "rustversion", - "winapi", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "thiserror" -version = "1.0.31" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "thiserror-impl", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "thiserror-impl" -version = "1.0.31" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-shared", ] [[package]] -name = "thread_local" -version = "1.1.4" +name = "wasm-bindgen-shared" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ - "once_cell", + "unicode-ident", ] [[package]] -name = "time" -version = "0.3.11" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "itoa", - "libc", - "num_threads", - "time-macros", + "leb128fmt", + "wasmparser", ] [[package]] -name = "time-macros" -version = "0.2.4" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] [[package]] -name = "unicode-ident" -version = "1.0.1" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "wit-bindgen-rt", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] [[package]] -name = "windows-sys" -version = "0.42.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows-targets", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] -name = "windows-targets" -version = "0.52.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows-link", ] [[package]] @@ -455,24 +759,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -480,70 +772,119 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] -name = "windows_i686_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.5" +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] -name = "windows_i686_msvc" +name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] -name = "windows_i686_msvc" -version = "0.52.5" +name = "windows_x86_64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] -name = "windows_x86_64_gnu" +name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] -name = "windows_x86_64_gnu" -version = "0.52.5" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.5" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags 2.9.1", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index 40e1a7a60fddc..3e400552cc07d 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" authors = ["Microsoft "] build = "build.rs" @@ -9,7 +9,7 @@ byteorder = "1.4.3" crc = "3.0.1" slog = "2.7.0" slog-async = "2.7.0" -slog-term = "2.9.1" +slog-term = "2.9.2" tempfile = "3.5.0" [target.'cfg(windows)'.dependencies.windows-sys] diff --git a/build/win32/code.iss b/build/win32/code.iss index f7091b28e5597..a61eef9c066e7 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,15 @@ 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 ; Environment #if "user" == InstallTarget @@ -1519,6 +1527,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 +1632,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 +1666,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 +1688,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 +1737,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 +1748,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 +1762,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 +1773,7 @@ begin end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); - RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\Drive\shell\{#RegValueName}'); + DeleteLegacyContextMenuRegistryKeys(); end; #endif @@ -1799,6 +1886,7 @@ begin if not CurUninstallStep = usUninstall then begin exit; end; + #ifdef AppxPackageName RemoveAppxPackage(); #endif diff --git a/build/win32/inno_updater.exe b/build/win32/inno_updater.exe index c3c4a0cd2bcb8..424e997bde5b9 100644 Binary files a/build/win32/inno_updater.exe and b/build/win32/inno_updater.exe differ diff --git a/cglicenses.json b/cglicenses.json index 2b1bc6fece5b6..48d2c3b093c9c 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -306,11 +306,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 +356,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 +698,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", diff --git a/cgmanifest.json b/cgmanifest.json index 21554434500a7..1b1e1711ccfd8 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "a229dbf7a56336b847b34dfff1bac79afc311eee", - "tag": "39.6.0" + "commitHash": "69c8cbf259da0f84e9c1db04958516a68f7170aa", + "tag": "39.8.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.6.0" + "version": "39.8.0" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index cd9b8de6afba6..afe353213b1b5 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" @@ -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/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 61cacaea79978..6e21ddb37297a 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -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 @@ -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 @@ -10517,7 +10541,34 @@ SOFTWARE. --------------------------------------------------------- +toml 0.9.12+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + toml_datetime 0.6.11 - MIT OR Apache-2.0 +toml_datetime 0.7.5+spec-1.1.0 - MIT OR Apache-2.0 https://github.com/toml-rs/toml ../../LICENSE-MIT @@ -10533,6 +10584,58 @@ https://github.com/toml-rs/toml --------------------------------------------------------- +toml_parser 1.0.9+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + +toml_writer 1.0.6+spec-1.1.0 - MIT OR Apache-2.0 +https://github.com/toml-rs/toml + +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + tower-service 0.3.3 - MIT https://github.com/tower-rs/tower @@ -12700,6 +12803,7 @@ MIT License --------------------------------------------------------- winnow 0.5.40 - MIT +winnow 0.7.14 - MIT https://github.com/winnow-rs/winnow The MIT License (MIT) @@ -12755,6 +12859,40 @@ THE SOFTWARE. --------------------------------------------------------- +winresource 0.1.30 - MIT +https://github.com/BenjaminRi/winresource + +The MIT License (MIT) + +Copyright 2016 Max Resch + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + wit-bindgen 0.51.0 - Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT https://github.com/bytecodealliance/wit-bindgen @@ -13531,33 +13669,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a zbus 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13565,33 +13677,7 @@ DEALINGS IN THE SOFTWARE. zbus_macros 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13599,33 +13685,7 @@ DEALINGS IN THE SOFTWARE. zbus_names 2.6.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14071,33 +14131,7 @@ DEALINGS IN THE SOFTWARE. zvariant 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14105,33 +14139,7 @@ DEALINGS IN THE SOFTWARE. zvariant_derive 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14139,31 +14147,5 @@ DEALINGS IN THE SOFTWARE. zvariant_utils 1.0.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 93a8a1b7b4396..5dadd67e26ec6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -828,6 +828,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: [ @@ -1426,6 +1456,7 @@ export default tseslint.config( // - electron-main 'when': 'hasNode', 'allow': [ + '@anthropic-ai/sandbox-runtime', '@parcel/watcher', '@vscode/sqlite3', '@vscode/vscode-languagedetection', @@ -1935,6 +1966,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 +1975,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': [ @@ -1955,7 +2056,8 @@ export default tseslint.config( 'vs/workbench/browser/**', 'vs/workbench/contrib/**', 'vs/workbench/services/*/~', - 'vs/sessions/~' + 'vs/sessions/~', + 'vs/sessions/services/*/~' ] }, { @@ -1974,6 +2076,32 @@ export default tseslint.config( 'vs/sessions/contrib/*/~' ] }, + { + 'target': 'src/vs/sessions/services/*/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/services/*/~', + 'vs/sessions/~', + 'vs/sessions/services/*/~', + { + 'when': 'test', + 'pattern': 'vs/workbench/contrib/*/~' + }, // TODO@layers + 'tas-client', // node module allowed even in /common/ + 'vscode-textmate', // node module allowed even in /common/ + '@vscode/vscode-languagedetection', // node module allowed even in /common/ + '@vscode/tree-sitter-wasm', // type import + { + 'when': 'hasBrowser', + 'pattern': '@xterm/xterm' + } // node module allowed even in /browser/ + ] + }, ] } }, @@ -2147,21 +2275,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 @@ -2227,5 +2347,4 @@ export default tseslint.config( }, ], } - }, -); + }); diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 0000000000000..cfaf6b0ca8dc1 --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Built-In Extensions + +This directory contains built-in extensions that ship with VS Code. + +## Basic Structure + +A typical TypeScript-based built-in extension has the following structure: + +- `package.json`: extension manifest. +- `src/`: Main directory for TypeScript source code. +- `tsconfig.json`: primary TypeScript config. This should inherit from `tsconfig.base.json`. +- `esbuild.mts`: esbuild build script used for production builds. +- `.vscodeignore`: Ignore file list. You can copy this from an existing extension. + +TypeScript-based extensions have the following output structure: + +- `out`: Output directory for development builds +- `dist`: Output directory for production builds. + + +## Enabling an Extension in the Browser + +By default extensions will only target desktop. To enable an extension in browsers as well: + +- Add a `"browser"` entry in `package.json` pointing to the browser bundle (for example `"./dist/browser/extension"`). +- Add `tsconfig.browser.json` that typechecks only browser-safe sources. +- Add an `esbuild.browser.mts` file. This should set `platform: 'browser'`. + +Make sure the browser build of the extension only uses browser-safe APIs. If an extension needs different behavior between desktop and web, you can create distinct entrypoints for each target: + +- `src/extension.ts`: Desktop entrypoint. +- `src/extension.browser.ts`: Browser entrypoint. Make sure `esbuild.browser.mts` builds this and that `tsconfig.browser.json` targets it. diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index 231eda54dba77..e5cf7c26d199e 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -51,9 +51,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", - "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index 1fd31eeae793b..0abce3a2cfb48 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -218,7 +218,7 @@ }, "scope": "resource", "default": [], - "description": "%css.lint.validProperties.desc%" + "markdownDescription": "%css.lint.validProperties.desc%" }, "css.lint.ieHack": { "type": "string", @@ -534,7 +534,7 @@ }, "scope": "resource", "default": [], - "description": "%scss.lint.validProperties.desc%" + "markdownDescription": "%scss.lint.validProperties.desc%" }, "scss.lint.ieHack": { "type": "string", @@ -840,7 +840,7 @@ }, "scope": "resource", "default": [], - "description": "%less.lint.validProperties.desc%" + "markdownDescription": "%less.lint.validProperties.desc%" }, "less.lint.ieHack": { "type": "string", diff --git a/extensions/css-language-features/package.nls.json b/extensions/css-language-features/package.nls.json index 057ec214bc2f8..d3de22412c286 100644 --- a/extensions/css-language-features/package.nls.json +++ b/extensions/css-language-features/package.nls.json @@ -33,7 +33,7 @@ "css.format.enable.desc": "Enable/disable default CSS formatter.", "css.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "css.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "css.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "css.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "css.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "css.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "css.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#css.format.preserveNewLines#` is enabled.", @@ -67,7 +67,7 @@ "less.format.enable.desc": "Enable/disable default LESS formatter.", "less.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "less.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "less.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "less.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "less.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "less.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "less.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#less.format.preserveNewLines#` is enabled.", @@ -101,7 +101,7 @@ "scss.format.enable.desc": "Enable/disable default SCSS formatter.", "scss.format.newlineBetweenSelectors.desc": "Separate selectors with a new line.", "scss.format.newlineBetweenRules.desc": "Separate rulesets by a blank line.", - "scss.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators '>', '+', '~' (e.g. `a > b`).", + "scss.format.spaceAroundSelectorSeparator.desc": "Ensure a space character around selector separators `>`, `+`, `~` (e.g. `a > b`).", "scss.format.braceStyle.desc": "Put braces on the same line as rules (`collapse`) or put braces on own line (`expand`).", "scss.format.preserveNewLines.desc": "Whether existing line breaks before rules and declarations should be preserved.", "scss.format.maxPreserveNewLines.desc": "Maximum number of line breaks to be preserved in one chunk, when `#scss.format.preserveNewLines#` is enabled.", diff --git a/extensions/css/cgmanifest.json b/extensions/css/cgmanifest.json index 7b85089b6b91a..93bd8ba0f3109 100644 --- a/extensions/css/cgmanifest.json +++ b/extensions/css/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "microsoft/vscode-css", "repositoryUrl": "https://github.com/microsoft/vscode-css", - "commitHash": "a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887" + "commitHash": "9a07d76cb0e7a56f9bfc76328a57227751e4adb4" } }, "licenseDetail": [ diff --git a/extensions/css/syntaxes/css.tmLanguage.json b/extensions/css/syntaxes/css.tmLanguage.json index 5ba8bc90b7381..484af027c195c 100644 --- a/extensions/css/syntaxes/css.tmLanguage.json +++ b/extensions/css/syntaxes/css.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/microsoft/vscode-css/commit/a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887", + "version": "https://github.com/microsoft/vscode-css/commit/9a07d76cb0e7a56f9bfc76328a57227751e4adb4", "name": "CSS", "scopeName": "source.css", "patterns": [ @@ -1401,7 +1401,7 @@ "property-keywords": { "patterns": [ { - "match": "(?xi) (? un interface RunConfig { readonly platform: 'node' | 'browser'; + readonly format?: 'cjs' | 'esm'; readonly srcDir: string; readonly outdir: string; readonly entryPoints: string[] | Record | { in: string; out: string }[]; @@ -48,6 +49,7 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { sourcemap: true, target: ['es2024'], external: ['vscode'], + format: config.format ?? 'cjs', entryPoints: config.entryPoints, outdir, logOverride: { @@ -57,10 +59,8 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { }; if (config.platform === 'node') { - options.format = 'cjs'; options.mainFields = ['module', 'main']; } else if (config.platform === 'browser') { - options.format = 'cjs'; options.mainFields = ['browser', 'module', 'main']; options.alias = { 'path': 'path-browserify', diff --git a/extensions/extension-editing/esbuild.browser.mts b/extensions/extension-editing/esbuild.browser.mts index 170f3cda31380..58b5fb7d6d5fa 100644 --- a/extensions/extension-editing/esbuild.browser.mts +++ b/extensions/extension-editing/esbuild.browser.mts @@ -15,4 +15,7 @@ run({ }, srcDir, outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, }, process.argv); diff --git a/extensions/extension-editing/package-lock.json b/extensions/extension-editing/package-lock.json index be1aa96eea6cb..d96f9a2bccacf 100644 --- a/extensions/extension-editing/package-lock.json +++ b/extensions/extension-editing/package-lock.json @@ -14,18 +14,37 @@ "parse5": "^3.0.2" }, "devDependencies": { - "@types/markdown-it": "0.0.2", + "@types/markdown-it": "^14", "@types/node": "22.x" }, "engines": { "vscode": "^1.4.0" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-0.0.2.tgz", - "integrity": "sha1-XZrRnm5lCM3S8llt+G/Qqt5ZhmA= sha512-A2seE+zJYSjGHy7L/v0EN/xRfgv2A60TuXOwI8tt5aZxF4UeoYIkM2jERnNH8w4VFr7oFEm0lElGOao7fZgygQ==", - "dev": true + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { "version": "22.13.10", diff --git a/extensions/extension-editing/package.json b/extensions/extension-editing/package.json index 3e277dbbfd385..c491fbedca2f5 100644 --- a/extensions/extension-editing/package.json +++ b/extensions/extension-editing/package.json @@ -66,7 +66,7 @@ ] }, "devDependencies": { - "@types/markdown-it": "0.0.2", + "@types/markdown-it": "^14", "@types/node": "22.x" }, "repository": { diff --git a/extensions/extension-editing/src/extensionEditingBrowserMain.ts b/extensions/extension-editing/src/extensionEditingBrowserMain.ts index f9d6885c6223c..57c969d017020 100644 --- a/extensions/extension-editing/src/extensionEditingBrowserMain.ts +++ b/extensions/extension-editing/src/extensionEditingBrowserMain.ts @@ -5,11 +5,14 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; export function activate(context: vscode.ExtensionContext) { //package.json suggestions context.subscriptions.push(registerPackageDocumentCompletions()); + //package.json go to definition for NLS strings + context.subscriptions.push(new PackageDocumentL10nSupport()); } function registerPackageDocumentCompletions(): vscode.Disposable { @@ -18,5 +21,4 @@ function registerPackageDocumentCompletions(): vscode.Disposable { return new PackageDocument(document).provideCompletionItems(position, token); } }); - } diff --git a/extensions/extension-editing/src/extensionEditingMain.ts b/extensions/extension-editing/src/extensionEditingMain.ts index c056fbfa975ae..c620b3039541f 100644 --- a/extensions/extension-editing/src/extensionEditingMain.ts +++ b/extensions/extension-editing/src/extensionEditingMain.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { PackageDocument } from './packageDocumentHelper'; +import { PackageDocumentL10nSupport } from './packageDocumentL10nSupport'; import { ExtensionLinter } from './extensionLinter'; export function activate(context: vscode.ExtensionContext) { @@ -15,6 +16,9 @@ export function activate(context: vscode.ExtensionContext) { //package.json code actions for lint warnings context.subscriptions.push(registerCodeActionsProvider()); + // package.json l10n support + context.subscriptions.push(new PackageDocumentL10nSupport()); + context.subscriptions.push(new ExtensionLinter()); } diff --git a/extensions/extension-editing/src/extensionLinter.ts b/extensions/extension-editing/src/extensionLinter.ts index 5c73304b4d891..6249500e2d171 100644 --- a/extensions/extension-editing/src/extensionLinter.ts +++ b/extensions/extension-editing/src/extensionLinter.ts @@ -8,7 +8,7 @@ import * as fs from 'fs'; import { URL } from 'url'; import { parseTree, findNodeAtLocation, Node as JsonNode, getNodeValue } from 'jsonc-parser'; -import * as MarkdownItType from 'markdown-it'; +import type MarkdownIt from 'markdown-it'; import { commands, languages, workspace, Disposable, TextDocument, Uri, Diagnostic, Range, DiagnosticSeverity, Position, env, l10n } from 'vscode'; import { INormalizedVersion, normalizeVersion, parseVersion } from './extensionEngineValidation'; @@ -44,7 +44,7 @@ enum Context { } interface TokenAndPosition { - token: MarkdownItType.Token; + token: MarkdownIt.Token; begin: number; end: number; } @@ -67,7 +67,7 @@ export class ExtensionLinter { private packageJsonQ = new Set(); private readmeQ = new Set(); private timer: NodeJS.Timeout | undefined; - private markdownIt: MarkdownItType.MarkdownIt | undefined; + private markdownIt: MarkdownIt | undefined; private parse5: typeof import('parse5') | undefined; constructor() { @@ -292,7 +292,7 @@ export class ExtensionLinter { this.markdownIt = new ((await import('markdown-it')).default); } const tokens = this.markdownIt.parse(text, {}); - const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownItType.Token[], begin = 0, end = text.length): TokenAndPosition[] { + const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownIt.Token[], begin = 0, end = text.length): TokenAndPosition[] { const tokensAndPositions = tokens.map(token => { if (token.map) { const tokenBegin = document.offsetAt(new Position(token.map[0], 0)); @@ -313,7 +313,7 @@ export class ExtensionLinter { }); return tokensAndPositions.concat( ...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length) - .map(tnp => toTokensAndPositions.call(this, tnp.token.children, tnp.begin, tnp.end)) + .map(tnp => toTokensAndPositions.call(this, tnp.token.children ?? [], tnp.begin, tnp.end)) ); }).call(this, tokens); @@ -373,7 +373,7 @@ export class ExtensionLinter { } } - private locateToken(text: string, begin: number, end: number, token: MarkdownItType.Token, content: string | null) { + private locateToken(text: string, begin: number, end: number, token: MarkdownIt.Token, content: string | null) { if (content) { const tokenBegin = text.indexOf(content, begin); if (tokenBegin !== -1) { diff --git a/extensions/extension-editing/src/packageDocumentL10nSupport.ts b/extensions/extension-editing/src/packageDocumentL10nSupport.ts new file mode 100644 index 0000000000000..4d844e98d5f71 --- /dev/null +++ b/extensions/extension-editing/src/packageDocumentL10nSupport.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { getLocation, getNodeValue, parseTree, findNodeAtLocation, visit } from 'jsonc-parser'; + + +const packageJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.json' }; +const packageNlsJsonSelector: vscode.DocumentSelector = { language: 'json', pattern: '**/package.nls.json' }; + +export class PackageDocumentL10nSupport implements vscode.DefinitionProvider, vscode.ReferenceProvider, vscode.Disposable { + + private readonly _disposables: vscode.Disposable[] = []; + + constructor() { + this._disposables.push(vscode.languages.registerDefinitionProvider(packageJsonSelector, this)); + this._disposables.push(vscode.languages.registerDefinitionProvider(packageNlsJsonSelector, this)); + + this._disposables.push(vscode.languages.registerReferenceProvider(packageNlsJsonSelector, this)); + this._disposables.push(vscode.languages.registerReferenceProvider(packageJsonSelector, this)); + } + + dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + } + + public async provideDefinition(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken): Promise { + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.json') { + return this.provideNlsValueDefinition(document, position); + } + + if (basename === 'package.nls.json') { + return this.provideNlsKeyDefinition(document, position); + } + + return undefined; + } + + private async provideNlsValueDefinition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.resolveNlsDefinition(nlsRef, nlsUri); + } + + private async provideNlsKeyDefinition(nlsDoc: vscode.TextDocument, position: vscode.Position): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + return this.resolveNlsDefinition(nlsKey, nlsDoc.uri); + } + + private async resolveNlsDefinition(origin: { key: string; range: vscode.Range }, nlsUri: vscode.Uri): Promise { + const target = await this.findNlsKeyDeclaration(origin.key, nlsUri); + if (!target) { + return undefined; + } + + return [{ + originSelectionRange: origin.range, + targetUri: target.uri, + targetRange: target.range, + }]; + } + + private getNlsReferenceAtPosition(packageJsonDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(packageJsonDoc.getText(), packageJsonDoc.offsetAt(position)); + if (!location.previousNode || location.previousNode.type !== 'string') { + return undefined; + } + + const value = getNodeValue(location.previousNode); + if (typeof value !== 'string') { + return undefined; + } + + const match = value.match(/^%(.+)%$/); + if (!match) { + return undefined; + } + + const nodeStart = packageJsonDoc.positionAt(location.previousNode.offset); + const nodeEnd = packageJsonDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key: match[1], range: new vscode.Range(nodeStart, nodeEnd) }; + } + + public async provideReferences(document: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext, _token: vscode.CancellationToken): Promise { + const basename = document.uri.path.split('/').pop()?.toLowerCase(); + if (basename === 'package.nls.json') { + return this.provideNlsKeyReferences(document, position, context); + } + if (basename === 'package.json') { + return this.provideNlsValueReferences(document, position, context); + } + return undefined; + } + + private async provideNlsKeyReferences(nlsDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsKey = this.getNlsKeyDefinitionAtPosition(nlsDoc, position); + if (!nlsKey) { + return undefined; + } + + const packageJsonUri = vscode.Uri.joinPath(nlsDoc.uri, '..', 'package.json'); + return this.findAllNlsReferences(nlsKey.key, packageJsonUri, nlsDoc.uri, context); + } + + private async provideNlsValueReferences(packageJsonDoc: vscode.TextDocument, position: vscode.Position, context: vscode.ReferenceContext): Promise { + const nlsRef = this.getNlsReferenceAtPosition(packageJsonDoc, position); + if (!nlsRef) { + return undefined; + } + + const nlsUri = vscode.Uri.joinPath(packageJsonDoc.uri, '..', 'package.nls.json'); + return this.findAllNlsReferences(nlsRef.key, packageJsonDoc.uri, nlsUri, context); + } + + private async findAllNlsReferences(nlsKey: string, packageJsonUri: vscode.Uri, nlsUri: vscode.Uri, context: vscode.ReferenceContext): Promise { + const locations = await this.findNlsReferencesInPackageJson(nlsKey, packageJsonUri); + + if (context.includeDeclaration) { + const decl = await this.findNlsKeyDeclaration(nlsKey, nlsUri); + if (decl) { + locations.push(decl); + } + } + + return locations; + } + + private async findNlsKeyDeclaration(nlsKey: string, nlsUri: vscode.Uri): Promise { + try { + const nlsDoc = await vscode.workspace.openTextDocument(nlsUri); + const nlsTree = parseTree(nlsDoc.getText()); + if (!nlsTree) { + return undefined; + } + + const node = findNodeAtLocation(nlsTree, [nlsKey]); + if (!node?.parent) { + return undefined; + } + + const keyNode = node.parent.children?.[0]; + if (!keyNode) { + return undefined; + } + + const start = nlsDoc.positionAt(keyNode.offset); + const end = nlsDoc.positionAt(keyNode.offset + keyNode.length); + return new vscode.Location(nlsUri, new vscode.Range(start, end)); + } catch { + return undefined; + } + } + + private async findNlsReferencesInPackageJson(nlsKey: string, packageJsonUri: vscode.Uri): Promise { + let packageJsonDoc: vscode.TextDocument; + try { + packageJsonDoc = await vscode.workspace.openTextDocument(packageJsonUri); + } catch { + return []; + } + + const text = packageJsonDoc.getText(); + const needle = `%${nlsKey}%`; + const locations: vscode.Location[] = []; + + visit(text, { + onLiteralValue(value, offset, length) { + if (value === needle) { + const start = packageJsonDoc.positionAt(offset); + const end = packageJsonDoc.positionAt(offset + length); + locations.push(new vscode.Location(packageJsonUri, new vscode.Range(start, end))); + } + } + }); + + return locations; + } + + private getNlsKeyDefinitionAtPosition(nlsDoc: vscode.TextDocument, position: vscode.Position): { key: string; range: vscode.Range } | undefined { + const location = getLocation(nlsDoc.getText(), nlsDoc.offsetAt(position)); + + // Must be on a top-level property key + if (location.path.length !== 1 || !location.isAtPropertyKey || !location.previousNode) { + return undefined; + } + + const key = location.path[0] as string; + const start = nlsDoc.positionAt(location.previousNode.offset); + const end = nlsDoc.positionAt(location.previousNode.offset + location.previousNode.length); + return { key, range: new vscode.Range(start, end) }; + } +} diff --git a/extensions/git/.vscodeignore b/extensions/git/.vscodeignore index a1fc5df7d26b8..9de840770944a 100644 --- a/extensions/git/.vscodeignore +++ b/extensions/git/.vscodeignore @@ -3,5 +3,5 @@ test/** out/** tsconfig*.json build/** -extension.webpack.config.js +esbuild*.mts package-lock.json diff --git a/extensions/git/esbuild.mts b/extensions/git/esbuild.mts new file mode 100644 index 0000000000000..1b397880bc6ca --- /dev/null +++ b/extensions/git/esbuild.mts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * 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, +}, process.argv, copyNonTsFiles); diff --git a/extensions/git/package-lock.json b/extensions/git/package-lock.json index b552ce9fa5b3f..9d6f684de6102 100644 --- a/extensions/git/package-lock.json +++ b/extensions/git/package-lock.json @@ -12,7 +12,7 @@ "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", "byline": "^5.0.0", - "file-type": "16.5.4", + "file-type": "21.3.1", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" @@ -28,6 +28,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 +171,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", @@ -226,17 +254,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.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.1.tgz", + "integrity": "sha512-SrzXX46I/zsRDjTb82eucsGg0ODq2NpGDp4HcsFKApPy8P8vACjpJRDoGGMfEzhFC0ry61ajd7f72J3603anBA==", + "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 +306,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + ], + "license": "BSD-3-Clause" }, "node_modules/isexe": { "version": "3.1.1", @@ -274,17 +317,11 @@ "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/picomatch": { "version": "2.3.1", @@ -297,91 +334,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 +387,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..8ff55b2a41576 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": [ @@ -4235,7 +4348,7 @@ "@joaomoreno/unique-names-generator": "^5.2.0", "@vscode/extension-telemetry": "^0.9.8", "byline": "^5.0.0", - "file-type": "16.5.4", + "file-type": "21.3.1", "picomatch": "2.3.1", "vscode-uri": "^2.0.0", "which": "4.0.0" diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index 9d469e33c84e9..147a75f9b7024 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -129,7 +129,7 @@ "command.stashView": "View Stash...", "command.stashView2": "View Stash", "command.timelineOpenDiff": "Open Changes", - "command.timelineCopyCommitId": "Copy Commit ID", + "command.timelineCopyCommitId": "Copy Commit Hash", "command.timelineCopyCommitMessage": "Copy Commit Message", "command.timelineSelectForCompare": "Select for Compare", "command.timelineCompareWithSelected": "Compare with Selected", @@ -148,6 +148,11 @@ "command.graphCompareWithMergeBase": "Compare with Merge Base", "command.graphCompareWithRemote": "Compare with Remote", "command.deleteRef": "Delete", + "command.artifactCopyCommitHash": "Copy Commit Hash", + "command.artifactCopyBranchName": "Copy Branch Name", + "command.artifactCopyTagName": "Copy Tag Name", + "command.artifactCopyStashName": "Copy Stash Name", + "command.artifactCopyWorktreePath": "Copy Worktree Path", "command.blameToggleEditorDecoration": "Toggle Git Blame Editor Decoration", "command.blameToggleStatusBarItem": "Toggle Git Blame Status Bar Item", "command.api.getRepositories": "Get Repositories", diff --git a/extensions/git/src/actionButton.ts b/extensions/git/src/actionButton.ts index 63eefb1de028a..5804c23f69755 100644 --- a/extensions/git/src/actionButton.ts +++ b/extensions/git/src/actionButton.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable, Event, EventEmitter, SourceControlActionButton, Uri, workspace, l10n, LogOutputChannel } from 'vscode'; -import { Branch, RefType, Status } from './api/git'; +import type { Branch } from './api/git'; +import { RefType, Status } from './api/git.constants'; import { OperationKind } from './operation'; import { CommitCommandsCenter } from './postCommitCommands'; import { Repository } from './repository'; diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 0791401665ec0..b97337596d1bd 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'; @@ -319,6 +320,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 +351,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..8a258ba5741ff 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -315,6 +315,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 +326,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/editSessionIdentityProvider.ts b/extensions/git/src/editSessionIdentityProvider.ts index 8380f03ecfd94..a3336d441743d 100644 --- a/extensions/git/src/editSessionIdentityProvider.ts +++ b/extensions/git/src/editSessionIdentityProvider.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { RefType } from './api/git'; +import { RefType } from './api/git.constants'; import { Model } from './model'; export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentityProvider, vscode.Disposable { diff --git a/extensions/git/src/fileSystemProvider.ts b/extensions/git/src/fileSystemProvider.ts index 19928863832a5..a09d00bfc2268 100644 --- a/extensions/git/src/fileSystemProvider.ts +++ b/extensions/git/src/fileSystemProvider.ts @@ -131,6 +131,26 @@ export class GitFileSystemProvider implements FileSystemProvider { this.cache = cache; } + private async getOrOpenRepository(uri: string | Uri): Promise { + let repository = this.model.getRepository(uri); + if (repository) { + return repository; + } + + // In case of the empty window, or the agent sessions window, no repositories are open + // so we need to explicitly open a repository before we can serve git content for the + // given git resource. + if (workspace.workspaceFolders === undefined || workspace.isAgentSessionsWorkspace) { + const fsPath = typeof uri === 'string' ? uri : fromGitUri(uri).path; + this.logger.info(`[GitFileSystemProvider][getOrOpenRepository] Opening repository for ${fsPath}`); + + await this.model.openRepository(fsPath, true, true); + repository = this.model.getRepository(uri); + } + + return repository; + } + watch(): Disposable { return EmptyDisposable; } @@ -139,7 +159,11 @@ export class GitFileSystemProvider implements FileSystemProvider { await this.model.isInitialized; const { submoduleOf, path, ref } = fromGitUri(uri); - const repository = submoduleOf ? this.model.getRepository(submoduleOf) : this.model.getRepository(uri); + + const repository = submoduleOf + ? await this.getOrOpenRepository(submoduleOf) + : await this.getOrOpenRepository(uri); + if (!repository) { this.logger.warn(`[GitFileSystemProvider][stat] Repository not found - ${uri.toString()}`); throw FileSystemError.FileNotFound(); @@ -175,7 +199,7 @@ export class GitFileSystemProvider implements FileSystemProvider { const { path, ref, submoduleOf } = fromGitUri(uri); if (submoduleOf) { - const repository = this.model.getRepository(submoduleOf); + const repository = await this.getOrOpenRepository(submoduleOf); if (!repository) { throw FileSystemError.FileNotFound(); @@ -190,7 +214,7 @@ export class GitFileSystemProvider implements FileSystemProvider { } } - const repository = this.model.getRepository(uri); + const repository = await this.getOrOpenRepository(uri); if (!repository) { this.logger.warn(`[GitFileSystemProvider][readFile] Repository not found - ${uri.toString()}`); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5127ae0fbb95f..14e61bc3f8b70 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -10,10 +10,11 @@ import * as cp from 'child_process'; import { fileURLToPath } from 'url'; import which from 'which'; import { EventEmitter } from 'events'; -import * as filetype from 'file-type'; +import { fileTypeFromBuffer } from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter, Versions, isWindows, pathEquals, isMacintosh, isDescendant, relativePathWithNoFallback, Mutable } from './util'; import { CancellationError, CancellationToken, ConfigurationChangeEvent, LogOutputChannel, Progress, Uri, workspace } from 'vscode'; -import { Commit as ApiCommit, Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import type { Commit as ApiCommit, Ref, Branch, Remote, LogOptions, Change, CommitOptions, RefQuery as ApiRefQuery, InitOptions, DiffChange, Worktree as ApiWorktree } from './api/git'; +import { RefType, ForcePushMode, GitErrorCodes, Status } from './api/git.constants'; import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -377,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; export interface ICloneOptions { readonly parentPath: string; + readonly targetName?: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; readonly ref?: string; @@ -432,14 +434,16 @@ export class Git { } async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { - const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; let folderName = baseFolderName; let folderPath = path.join(options.parentPath, folderName); let count = 1; - while (count < 20 && await new Promise(c => exists(folderPath, c))) { - folderName = `${baseFolderName}-${count++}`; - folderPath = path.join(options.parentPath, folderName); + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } } await mkdirp(options.parentPath); @@ -1073,7 +1077,7 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; // Copy or Rename status comes with a number (ex: 'R100'). // We don't need the number, we use only first character of the status. @@ -1138,7 +1142,7 @@ function parseGitChangesRaw(repositoryRoot: string, raw: string): DiffChange[] { let uri = originalUri; let renameUri = originalUri; - let status = Status.UNTRACKED; + let status: Status = Status.UNTRACKED; switch (change[0]) { case 'A': @@ -1687,7 +1691,7 @@ export class Repository { } if (!isText) { - const result = await filetype.fromBuffer(buffer); + const result = await fileTypeFromBuffer(buffer); if (!result) { return { mimetype: 'application/octet-stream' }; @@ -2420,7 +2424,7 @@ export class Repository { await this.exec(args, spawnOptions); } - async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { const args = ['pull']; if (options.tags) { @@ -2446,10 +2450,11 @@ export class Repository { } try { - await this.exec(args, { + const result = await this.exec(args, { cancellationToken: options.cancellationToken, env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } }); + return !/Already up to date/i.test(result.stdout); } catch (err) { if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) { err.gitErrorCode = GitErrorCodes.Conflict; diff --git a/extensions/git/src/historyItemDetailsProvider.ts b/extensions/git/src/historyItemDetailsProvider.ts index be0e2b337f8f6..cccdf508fe3a8 100644 --- a/extensions/git/src/historyItemDetailsProvider.ts +++ b/extensions/git/src/historyItemDetailsProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, Disposable } from 'vscode'; -import { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { AvatarQuery, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Repository } from './repository'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 29e8705e04b35..c658b4c005eec 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -8,7 +8,8 @@ import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, Fil import { Repository, Resource } from './repository'; import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, subject, truncate } from './util'; import { toMultiFileDiffEditorUris } from './uri'; -import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref } from './api/git'; +import { RefType } from './api/git.constants'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index b37ae9c79c5b7..b2690b24a7cdd 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -12,7 +12,7 @@ import { GitDecorations } from './decorationProvider'; import { Askpass } from './askpass'; import { toDisposable, filterEvent, eventToPromise } from './util'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { GitExtension } from './api/git'; +import type { GitExtension } from './api/git'; import { GitProtocolHandler } from './protocolHandler'; import { GitExtensionImpl } from './api/extension'; import * as path from 'path'; diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index 1d65d3dc2d755..deecc7c28629a 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -12,7 +12,7 @@ import { Git } from './git'; import * as path from 'path'; import * as fs from 'fs'; import { fromGitUri } from './uri'; -import { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; +import type { APIState as State, CredentialsProvider, PushErrorHandler, PublishEvent, RemoteSourcePublisher, PostCommitCommandsProvider, BranchProtectionProvider, SourceControlHistoryItemDetailsProvider } from './api/git'; import { Askpass } from './askpass'; import { IPushErrorHandlerRegistry } from './pushError'; import { ApiRepository } from './api/api1'; diff --git a/extensions/git/src/postCommitCommands.ts b/extensions/git/src/postCommitCommands.ts index 69a18114a41e2..50658d14202ba 100644 --- a/extensions/git/src/postCommitCommands.ts +++ b/extensions/git/src/postCommitCommands.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Command, commands, Disposable, Event, EventEmitter, Memento, Uri, workspace, l10n } from 'vscode'; -import { PostCommitCommandsProvider } from './api/git'; +import type { PostCommitCommandsProvider } from './api/git'; import { IRepositoryResolver, Repository } from './repository'; import { ApiRepository } from './api/api1'; import { dispose } from './util'; diff --git a/extensions/git/src/pushError.ts b/extensions/git/src/pushError.ts index 6222923ff6864..71f564e8fa255 100644 --- a/extensions/git/src/pushError.ts +++ b/extensions/git/src/pushError.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vscode'; -import { PushErrorHandler } from './api/git'; +import type { PushErrorHandler } from './api/git'; export interface IPushErrorHandlerRegistry { registerPushErrorHandler(provider: PushErrorHandler): Disposable; diff --git a/extensions/git/src/quickDiffProvider.ts b/extensions/git/src/quickDiffProvider.ts index 3b1aa64c8faae..961f5387555fd 100644 --- a/extensions/git/src/quickDiffProvider.ts +++ b/extensions/git/src/quickDiffProvider.ts @@ -7,7 +7,7 @@ import { FileType, l10n, LogOutputChannel, QuickDiffProvider, Uri, workspace } f import { IRepositoryResolver, Repository } from './repository'; import { isDescendant, pathEquals } from './util'; import { toGitUri } from './uri'; -import { Status } from './api/git'; +import { Status } from './api/git.constants'; export class GitQuickDiffProvider implements QuickDiffProvider { readonly label = l10n.t('Git Local Changes (Working Tree)'); diff --git a/extensions/git/src/remotePublisher.ts b/extensions/git/src/remotePublisher.ts index 1326776cde4a0..eb8ec7b8e19bb 100644 --- a/extensions/git/src/remotePublisher.ts +++ b/extensions/git/src/remotePublisher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, Event } from 'vscode'; -import { RemoteSourcePublisher } from './api/git'; +import type { RemoteSourcePublisher } from './api/git'; export interface IRemoteSourcePublisherRegistry { readonly onDidAddRemoteSourcePublisher: Event; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index f3b1afb4689e0..446384bc3dbb0 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ 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, 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 +25,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'; @@ -952,7 +954,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 +967,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); @@ -1890,15 +1892,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', []); @@ -3293,7 +3321,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 }; + worktreeTask.runOptions = { ...task.runOptions }; + + return worktreeTask; +} + +function retargetTaskExecution(execution: ProcessExecution | ShellExecution | CustomExecution | undefined, worktreePath: string): ProcessExecution | ShellExecution | CustomExecution | undefined { + if (!execution) { + return undefined; + } + + if (execution instanceof ProcessExecution) { + return new ProcessExecution(execution.process, execution.args, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution instanceof ShellExecution) { + if (execution.commandLine !== undefined) { + return new ShellExecution(execution.commandLine, { + ...execution.options, + cwd: worktreePath + }); + } + + if (execution.command !== undefined) { + return new ShellExecution(execution.command, execution.args ?? [], { + ...execution.options, + cwd: worktreePath + }); + } + } + + return execution; +} diff --git a/extensions/git/src/repositoryCache.ts b/extensions/git/src/repositoryCache.ts index 6aa998b7679bb..8f03d8998c771 100644 --- a/extensions/git/src/repositoryCache.ts +++ b/extensions/git/src/repositoryCache.ts @@ -5,7 +5,7 @@ import { LogOutputChannel, Memento, Uri, workspace } from 'vscode'; import { LRUCache } from './cache'; -import { Remote, RepositoryAccessDetails } from './api/git'; +import type { Remote, RepositoryAccessDetails } from './api/git'; import { isDescendant } from './util'; export interface RepositoryCacheInfo { diff --git a/extensions/git/src/statusbar.ts b/extensions/git/src/statusbar.ts index d5cbe86ee7c88..32fb1f588642b 100644 --- a/extensions/git/src/statusbar.ts +++ b/extensions/git/src/statusbar.ts @@ -6,7 +6,8 @@ import { Disposable, Command, EventEmitter, Event, workspace, Uri, l10n } from 'vscode'; import { Repository } from './repository'; import { anyEvent, dispose, filterEvent } from './util'; -import { Branch, RefType, RemoteSourcePublisher } from './api/git'; +import type { Branch, RemoteSourcePublisher } from './api/git'; +import { RefType } from './api/git.constants'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { CheckoutOperation, CheckoutTrackingOperation, OperationKind } from './operation'; diff --git a/extensions/git/src/test/smoke.test.ts b/extensions/git/src/test/smoke.test.ts index d9a5776824b2e..c2870a2631ee3 100644 --- a/extensions/git/src/test/smoke.test.ts +++ b/extensions/git/src/test/smoke.test.ts @@ -9,7 +9,8 @@ import { workspace, commands, window, Uri, WorkspaceEdit, Range, TextDocument, e import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { GitExtension, API, Repository, Status } from '../api/git'; +import type { GitExtension, API, Repository } from '../api/git'; +import { Status } from '../api/git.constants'; import { eventToPromise } from '../util'; suite('git smoke test', function () { diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index 1ccf04a423d8d..a07eb4bfba78e 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -12,7 +12,7 @@ import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; import { truncate } from './util'; import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider'; -import { AvatarQuery, AvatarQueryCommit } from './api/git'; +import type { AvatarQuery, AvatarQueryCommit } from './api/git'; import { getCommitHover, getHoverCommitHashCommands, processHoverRemoteCommands } from './hover'; export class GitTimelineItem extends TimelineItem { diff --git a/extensions/git/src/uri.ts b/extensions/git/src/uri.ts index 8b04fabe583eb..1d79e67e8e67b 100644 --- a/extensions/git/src/uri.ts +++ b/extensions/git/src/uri.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Uri } from 'vscode'; -import { Change, Status } from './api/git'; +import type { Change } from './api/git'; +import { Status } from './api/git.constants'; export interface GitUriParams { path: string; diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index c6ec6ece45c69..58a6d06419a78 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri, DiagnosticSeverity, env, SourceControlHistoryItem } from 'vscode'; -import { dirname, normalize, sep, relative } from 'path'; +import { basename, dirname, normalize, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; import byline from 'byline'; @@ -867,10 +867,6 @@ export function getStashDescription(stash: Stash): string | undefined { return descriptionSegments.join(' \u2022 '); } -export function isCopilotWorktree(path: string): boolean { - const lastSepIndex = path.lastIndexOf(sep); - - return lastSepIndex !== -1 - ? path.substring(lastSepIndex + 1).startsWith('copilot-worktree-') - : path.startsWith('copilot-worktree-'); +export function isCopilotWorktreeFolder(path: string): boolean { + return basename(path).startsWith('copilot-'); } diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 2a7ad5259acca..a34d12aaa4838 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -27,6 +27,7 @@ "../../src/vscode-dts/vscode.proposed.scmMultiDiffEditor.d.ts", "../../src/vscode-dts/vscode.proposed.scmTextDocument.d.ts", "../../src/vscode-dts/vscode.proposed.statusBarItemTooltip.d.ts", + "../../src/vscode-dts/vscode.proposed.taskRunOptions.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputMultiDiff.d.ts", "../../src/vscode-dts/vscode.proposed.tabInputTextMerge.d.ts", "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts", diff --git a/extensions/github-authentication/.vscodeignore b/extensions/github-authentication/.vscodeignore index 0f1797efe9561..fd8583ab8d125 100644 --- a/extensions/github-authentication/.vscodeignore +++ b/extensions/github-authentication/.vscodeignore @@ -3,7 +3,7 @@ src/** !src/common/config.json out/** build/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild.mts +esbuild.browser.mts tsconfig*.json package-lock.json diff --git a/extensions/github-authentication/esbuild.browser.mts b/extensions/github-authentication/esbuild.browser.mts new file mode 100644 index 0000000000000..20745e1d0870e --- /dev/null +++ b/extensions/github-authentication/esbuild.browser.mts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import type { Plugin } from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Plugin that rewrites `./node/*` imports to `./browser/*` for the web build, + * replacing the platform-specific implementations with their browser equivalents. + */ +const platformModulesPlugin: Plugin = { + name: 'platform-modules', + setup(build) { + build.onResolve({ filter: /\/node\// }, args => { + if (args.kind !== 'import-statement' || !args.resolveDir) { + return; + } + const remapped = args.path.replace('/node/', '/browser/'); + return build.resolve(remapped, { resolveDir: args.resolveDir, kind: args.kind }); + }); + }, +}; + +run({ + platform: 'browser', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [platformModulesPlugin], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/github/extension.webpack.config.js b/extensions/github-authentication/esbuild.mts similarity index 51% rename from extensions/github/extension.webpack.config.js rename to extensions/github-authentication/esbuild.mts index 9e2b191a389d4..2b75ca703da06 100644 --- a/extensions/github/extension.webpack.config.js +++ b/extensions/github-authentication/esbuild.mts @@ -2,22 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - }, - output: { - libraryTarget: 'module', - chunkFormat: 'module', - }, - externals: { - 'vscode': 'module vscode', +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, - experiments: { - outputModule: true - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/github-authentication/extension-browser.webpack.config.js b/extensions/github-authentication/extension-browser.webpack.config.js deleted file mode 100644 index 70a7fd87cf4a3..0000000000000 --- a/extensions/github-authentication/extension-browser.webpack.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - node: false, - entry: { - extension: './src/extension.ts', - }, - resolve: { - alias: { - 'uuid': path.resolve(import.meta.dirname, 'node_modules/uuid/dist/esm-browser/index.js'), - './node/authServer': path.resolve(import.meta.dirname, 'src/browser/authServer'), - './node/crypto': path.resolve(import.meta.dirname, 'src/browser/crypto'), - './node/fetch': path.resolve(import.meta.dirname, 'src/browser/fetch'), - './node/buffer': path.resolve(import.meta.dirname, 'src/browser/buffer'), - } - } -}); diff --git a/extensions/github-authentication/media/index.html b/extensions/github-authentication/media/index.html index 3292e2a08fc9f..2df45293528fa 100644 --- a/extensions/github-authentication/media/index.html +++ b/extensions/github-authentication/media/index.html @@ -30,9 +30,17 @@

Launching

- ${this._getStyles(resourceProvider, sourceUri, config, imageInfo)} + + ${this.#getStyles(resourceProvider, sourceUri, config, imageInfo)} - ${this._getScripts(resourceProvider, nonce)} + ${this.#getScripts(resourceProvider, nonce)} `; return { @@ -119,7 +131,7 @@ export class MdDocumentRenderer { markdownDocument: vscode.TextDocument, resourceProvider: WebviewResourceProvider, ): Promise { - const rendered = await this._engine.render(markdownDocument, resourceProvider); + const rendered = await this.#engine.render(markdownDocument, resourceProvider); const html = `
${rendered.html}
`; return { html, @@ -138,13 +150,13 @@ export class MdDocumentRenderer { `; } - private _extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string { + #extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string { const webviewResource = resourceProvider.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'media', mediaFile)); + vscode.Uri.joinPath(this.#context.extensionUri, 'media', mediaFile)); return webviewResource.toString(); } - private _fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string { + #fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string { if (!href) { return href; } @@ -168,18 +180,18 @@ export class MdDocumentRenderer { return resourceProvider.asWebviewUri(vscode.Uri.joinPath(uri.Utils.dirname(resource), href)).toString(); } - private _computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string { + #computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string { if (!Array.isArray(config.styles)) { return ''; } const out: string[] = []; for (const style of config.styles) { - out.push(``); + out.push(``); } return out.join('\n'); } - private _getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string { + #getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string { return [ config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '', isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`, @@ -187,7 +199,7 @@ export class MdDocumentRenderer { ].join(' '); } - private _getImageStabilizerStyles(imageInfo: readonly ImageInfo[]): string { + #getImageStabilizerStyles(imageInfo: readonly ImageInfo[]): string { if (!imageInfo.length) { return ''; } @@ -204,20 +216,20 @@ export class MdDocumentRenderer { return ret; } - private _getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, imageInfo: readonly ImageInfo[]): string { + #getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, imageInfo: readonly ImageInfo[]): string { const baseStyles: string[] = []; - for (const resource of this._contributionProvider.contributions.previewStyles) { + for (const resource of this.#contributionProvider.contributions.previewStyles) { baseStyles.push(``); } return `${baseStyles.join('\n')} - ${this._computeCustomStyleSheetIncludes(resourceProvider, resource, config)} - ${this._getImageStabilizerStyles(imageInfo)}`; + ${this.#computeCustomStyleSheetIncludes(resourceProvider, resource, config)} + ${this.#getImageStabilizerStyles(imageInfo)}`; } - private _getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string { + #getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string { const out: string[] = []; - for (const resource of this._contributionProvider.contributions.previewScripts) { + for (const resource of this.#contributionProvider.contributions.previewScripts) { out.push(` + + + + + +`; +} + +/** Recursively collect *.css paths relative to `dir`. */ +function collectCssFiles(dir, prefix) { + let results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? prefix + '/' + entry.name : entry.name; + if (entry.isDirectory()) { + results = results.concat(collectCssFiles(path.join(dir, entry.name), rel)); + } else if (entry.name.endsWith('.css')) { + results.push(rel); + } + } + return results; +} + +main(); + diff --git a/scripts/code-sessions-web.sh b/scripts/code-sessions-web.sh new file mode 100755 index 0000000000000..be62921a05f28 --- /dev/null +++ b/scripts/code-sessions-web.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +if [[ "$OSTYPE" == "darwin"* ]]; then + realpath() { [[ $1 = /* ]] && echo "$1" || echo "$PWD/${1#./}"; } + ROOT=$(dirname $(dirname $(realpath "$0"))) +else + ROOT=$(dirname $(dirname $(readlink -f $0))) +fi + +function code() { + cd $ROOT + + # Sync built-in extensions + npm run download-builtin-extensions + + NODE=$(node build/lib/node.ts) + if [ ! -e $NODE ];then + # Load remote node + npm run gulp node + fi + + NODE=$(node build/lib/node.ts) + + $NODE ./scripts/code-sessions-web.js "$@" +} + +code "$@" diff --git a/src/bootstrap-import.ts b/src/bootstrap-import.ts index 3bd5c73a0af64..70c5525a274b5 100644 --- a/src/bootstrap-import.ts +++ b/src/bootstrap-import.ts @@ -52,7 +52,6 @@ export async function resolve(specifier: string | number, context: unknown, next const newSpecifier = _specifierToUrl[specifier]; if (newSpecifier !== undefined) { return { - format: '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/tsconfig.vscode-dts.json b/src/tsconfig.vscode-dts.json index b83f686e4f3d3..fae0ce15c38f1 100644 --- a/src/tsconfig.vscode-dts.json +++ b/src/tsconfig.vscode-dts.json @@ -1,7 +1,7 @@ { "compilerOptions": { "noEmit": true, - "module": "None", + "module": "preserve", "experimentalDecorators": false, "noImplicitReturns": true, "noImplicitOverride": true, diff --git a/src/typings/base-common.d.ts b/src/typings/base-common.d.ts index 56e9a6a799d7e..9028abb2975b5 100644 --- a/src/typings/base-common.d.ts +++ b/src/typings/base-common.d.ts @@ -25,7 +25,7 @@ declare global { function setTimeout(handler: string | Function, timeout?: number, ...arguments: any[]): Timeout; function clearTimeout(timeout: Timeout | undefined): void; - function setInterval(callback: (...args: any[]) => void, delay?: number, ...args: any[]): Timeout; + function setInterval(callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]): Timeout; function clearInterval(timeout: Timeout | undefined): void; diff --git a/src/vs/amdX.ts b/src/vs/amdX.ts index 374d4f19faf13..98290fdc2b421 100644 --- a/src/vs/amdX.ts +++ b/src/vs/amdX.ts @@ -171,15 +171,15 @@ class AMDModuleImporter { if (this._amdPolicy) { scriptSrc = this._amdPolicy.createScriptURL(scriptSrc) as unknown as string; } - await import(scriptSrc); + await import(/* @vite-ignore */ scriptSrc); return this._defineCalls.pop(); } private async _nodeJSLoadScript(scriptSrc: string): Promise { try { - const fs = (await import(`${'fs'}`)).default; - const vm = (await import(`${'vm'}`)).default; - const module = (await import(`${'module'}`)).default; + const fs = (await import(/* @vite-ignore */ `${'fs'}`)).default; + const vm = (await import(/* @vite-ignore */ `${'vm'}`)).default; + const module = (await import(/* @vite-ignore */ `${'module'}`)).default; const filePath = URI.parse(scriptSrc).fsPath; const content = fs.readFileSync(filePath).toString(); diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index e3f20d96726b4..52f99538b1144 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -66,6 +66,25 @@ export interface MarkdownSanitizerConfig { readonly remoteImageIsAllowed?: (uri: URI) => boolean; } +/** + * Returns a human-readable tooltip string for a link href. + * For file:// URIs, converts to a decoded OS file system path to avoid + * showing raw URL-encoded paths (e.g. "C:\Users\..." instead of "file:///c%3A/Users/..."). + */ +function getLinkTitle(href: string): string { + try { + const parsed = URI.parse(href); + if (parsed.scheme === Schemas.file) { + const path = parsed.fsPath; + const fragment = parsed.fragment; + return escapeDoubleQuotes(fragment ? `${path}#${fragment}` : path); + } + } catch { + // fall through + } + return ''; +} + const defaultMarkedRenderers = Object.freeze({ image: ({ href, title, text }: marked.Tokens.Image): string => { let dimensions: string[] = []; @@ -104,6 +123,12 @@ const defaultMarkedRenderers = Object.freeze({ title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; href = removeMarkdownEscapes(href); + // For file:// URIs without an explicit title, show the decoded OS path instead of + // the raw URL-encoded URI (e.g. display "C:\Users\..." instead of "file:///c%3A/Users/...") + if (!title && href.startsWith(`${Schemas.file}:`)) { + title = getLinkTitle(href); + } + // HTML Encode href href = href.replace(/&/g, '&') .replace(/ .dialog-icon.codicon { - flex: 0 0 48px; - height: 48px; - font-size: 48px; + flex: 0 0 24px; + height: 24px; + font-size: 24px; } .monaco-dialog-box.align-vertical .dialog-message-row > .dialog-icon.codicon { @@ -76,12 +84,17 @@ align-self: baseline; } +.monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { + align-self: stretch; /* fill row height so overflow-y scrolling works */ +} + /** Dialog: Message/Footer Container */ .monaco-dialog-box .dialog-message-row .dialog-message-container, .monaco-dialog-box .dialog-footer-row { display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; text-overflow: ellipsis; user-select: text; -webkit-user-select: text; @@ -95,7 +108,7 @@ .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container, .monaco-dialog-box:not(.align-vertical) .dialog-footer-row { - padding-left: 24px; + padding-left: 12px; } .monaco-dialog-box.align-vertical .dialog-message-row .dialog-message-container, @@ -111,20 +124,20 @@ /** Dialog: Message */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { - line-height: 22px; - font-size: 18px; + font-size: 14px; + font-weight: 600; flex: 1; /* let the message always grow */ white-space: normal; word-wrap: break-word; /* never overflow long words, but break to next line */ - min-height: 48px; /* matches icon height */ - margin-bottom: 8px; + min-height: 22px; + margin-bottom: 4px; display: flex; align-items: center; } /** Dialog: Details */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { - line-height: 22px; + line-height: 20px; flex: 1; /* let the message always grow */ } @@ -167,12 +180,8 @@ align-items: center; padding-right: 1px; overflow: hidden; /* buttons row should never overflow */ -} - -.monaco-dialog-box > .dialog-buttons-row { - display: flex; white-space: nowrap; - padding: 20px 10px 10px; + padding: 20px 0px 0px; } /** Dialog: Buttons */ @@ -196,8 +205,8 @@ .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button { overflow: hidden; text-overflow: ellipsis; - margin: 4px 5px; /* allows button focus outline to be visible */ - outline-offset: 2px !important; + margin: 4px; /* allows button focus outline to be visible */ + outline-offset: 1px !important; } .monaco-dialog-box.align-vertical > .dialog-buttons-row > .dialog-buttons > .monaco-button { @@ -238,3 +247,7 @@ .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button-dropdown > .monaco-dropdown-button { padding: 0 4px; } + +.monaco-dialog-modal-block .dialog-shadow { + border-radius: var(--vscode-cornerRadius-xLarge); +} diff --git a/src/vs/base/browser/ui/dropdown/dropdown.css b/src/vs/base/browser/ui/dropdown/dropdown.css index bfcaee41f98ad..7c70f376b1491 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.css +++ b/src/vs/base/browser/ui/dropdown/dropdown.css @@ -20,6 +20,11 @@ cursor: default; } +.monaco-dropdown .dropdown-menu { + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); +} + .monaco-dropdown-with-primary { display: flex !important; flex-direction: row; diff --git a/src/vs/base/browser/ui/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..500b0614adeaf 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 { @@ -1022,7 +1020,7 @@ export function getMenuWidgetCSS(style: IMenuStyles, isForShadowDom: boolean): s let result = /* css */` .monaco-menu { font-size: 13px; - border-radius: 5px; + border-radius: var(--vscode-cornerRadius-large); min-width: 160px; } @@ -1137,11 +1135,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 +1239,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 +1271,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/common/actions.ts b/src/vs/base/common/actions.ts index 6d3e3f2b3db58..18641db33f93e 100644 --- a/src/vs/base/common/actions.ts +++ b/src/vs/base/common/actions.ts @@ -220,6 +220,24 @@ export class Separator implements IAction { return out; } + /** + * Removes leading, trailing, and consecutive duplicate separators in-place and returns the actions. + */ + public static clean(actions: IAction[]): IAction[] { + while (actions.length > 0 && actions[0].id === Separator.ID) { + actions.shift(); + } + while (actions.length > 0 && actions[actions.length - 1].id === Separator.ID) { + actions.pop(); + } + for (let i = actions.length - 2; i >= 0; i--) { + if (actions[i].id === Separator.ID && actions[i + 1].id === Separator.ID) { + actions.splice(i + 1, 1); + } + } + return actions; + } + static readonly ID = 'vs.actions.separator'; readonly id: string = Separator.ID; diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index 3dcfa0c513087..e5aaa42487681 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -1098,15 +1098,15 @@ export class IntervalTimer implements IDisposable { } } -export class RunOnceScheduler implements IDisposable { +export class RunOnceScheduler any = () => any> implements IDisposable { - protected runner: ((...args: unknown[]) => void) | null; + protected runner: Runner | null; private timeoutToken: Timeout | undefined; private timeout: number; private timeoutHandler: () => void; - constructor(runner: (...args: any[]) => void, delay: number) { + constructor(runner: Runner, delay: number) { this.timeoutToken = undefined; this.runner = runner; this.timeout = delay; @@ -1246,7 +1246,7 @@ export class ProcessTimeRunOnceScheduler { } } -export class RunOnceWorker extends RunOnceScheduler { +export class RunOnceWorker extends RunOnceScheduler<(units: T[]) => void> { private units: T[] = []; diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index f541d4face8e6..1a2d78bcd70ef 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -655,4 +655,5 @@ export const codiconsLibrary = { openai: register('openai', 0xec81), claude: register('claude', 0xec82), openInWindow: register('open-in-window', 0xec83), + newSession: register('new-session', 0xec84), } as const; diff --git a/src/vs/base/common/decorators.ts b/src/vs/base/common/decorators.ts index 7510ffcec1f4f..74d2e56f51e12 100644 --- a/src/vs/base/common/decorators.ts +++ b/src/vs/base/common/decorators.ts @@ -45,7 +45,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri } const memoizeKey = `$memoize$${key}`; - descriptor[fnKey!] = function (...args: any[]) { + descriptor[fnKey!] = function (this: any, ...args: unknown[]) { if (!this.hasOwnProperty(memoizeKey)) { Object.defineProperty(this, memoizeKey, { configurable: false, @@ -54,8 +54,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri value: fn.apply(this, args) }); } - // eslint-disable-next-line local/code-no-any-casts - return (this as any)[memoizeKey]; + return this[memoizeKey]; }; } diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 2d10cedc84d9d..3c53a69862215 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -48,6 +48,11 @@ export interface IPolicyData { readonly mcpAccess?: 'allow_all' | 'registry_only'; } +export interface ICopilotTokenInfo { + readonly sn?: string; + readonly fcv1?: string; +} + export interface IDefaultAccountAuthenticationProvider { readonly id: string; readonly name: string; diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index de0fce1d4fdd0..a9d495ab6b090 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 }); diff --git a/src/vs/base/common/htmlContent.ts b/src/vs/base/common/htmlContent.ts index 070279f045ae7..16049d7e6f731 100644 --- a/src/vs/base/common/htmlContent.ts +++ b/src/vs/base/common/htmlContent.ts @@ -210,9 +210,9 @@ export function createMarkdownLink(text: string, href: string, title?: string, e return `[${escapeTokens ? escapeMarkdownSyntaxTokens(text) : text}](${href}${title ? ` "${escapeMarkdownSyntaxTokens(title)}"` : ''})`; } -export function createMarkdownCommandLink(command: { title: string; id: string; arguments?: unknown[]; tooltip?: string }, escapeTokens = true): string { +export function createMarkdownCommandLink(command: { text: string; id: string; arguments?: unknown[]; tooltip: string }, escapeTokens = true): string { const uri = createCommandUri(command.id, ...(command.arguments || [])).toString(); - return createMarkdownLink(command.title, uri, command.tooltip, escapeTokens); + return createMarkdownLink(command.text, uri, command.tooltip, escapeTokens); } export function createCommandUri(commandId: string, ...commandArgs: unknown[]): URI { diff --git a/src/vs/base/common/jsonRpcProtocol.ts b/src/vs/base/common/jsonRpcProtocol.ts index 67c4ed4fc4d82..35d7144ba82bf 100644 --- a/src/vs/base/common/jsonRpcProtocol.ts +++ b/src/vs/base/common/jsonRpcProtocol.ts @@ -43,6 +43,7 @@ export interface IJsonRpcErrorResponse { } export type JsonRpcMessage = IJsonRpcRequest | IJsonRpcNotification | IJsonRpcSuccessResponse | IJsonRpcErrorResponse; +export type JsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; interface IPendingRequest { promise: DeferredPromise; @@ -122,15 +123,31 @@ export class JsonRpcProtocol extends Disposable { }) as Promise; } - public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { + /** + * Handles one or more incoming JSON-RPC messages. + * + * Returns an array of JSON-RPC response objects generated for any incoming + * requests in the message(s). Notifications and responses to our own + * outgoing requests do not produce return values. For batch inputs, the + * returned responses are in the same order as the corresponding requests. + * + * Note: responses are also emitted via the `_send` callback, so callers + * that rely on the return value should not re-send them. + */ + public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { if (Array.isArray(message)) { + const replies: JsonRpcResponse[] = []; for (const single of message) { - await this._handleMessage(single); + const reply = await this._handleMessage(single); + if (reply) { + replies.push(reply); + } } - return; + return replies; } - await this._handleMessage(message); + const reply = await this._handleMessage(message); + return reply ? [reply] : []; } public cancelPendingRequest(id: JsonRpcId): void { @@ -152,22 +169,25 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleMessage(message: JsonRpcMessage): Promise { + private async _handleMessage(message: JsonRpcMessage): Promise { if (isJsonRpcResponse(message)) { if (hasKey(message, { result: true })) { this._handleResult(message); } else { this._handleError(message); } + return undefined; } if (isJsonRpcRequest(message)) { - await this._handleRequest(message); + return this._handleRequest(message); } if (isJsonRpcNotification(message)) { this._handlers.handleNotification?.(message); } + + return undefined; } private _handleResult(response: IJsonRpcSuccessResponse): void { @@ -192,17 +212,18 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleRequest(request: IJsonRpcRequest): Promise { + private async _handleRequest(request: IJsonRpcRequest): Promise { if (!this._handlers.handleRequest) { - this._send({ + const response: IJsonRpcErrorResponse = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.MethodNotFound, message: `Method not found: ${request.method}`, } - }); - return; + }; + this._send(response); + return response; } const cts = new CancellationTokenSource(); @@ -211,14 +232,17 @@ export class JsonRpcProtocol extends Disposable { try { const resultOrThenable = this._handlers.handleRequest(request, cts.token); const result = isThenable(resultOrThenable) ? await resultOrThenable : resultOrThenable; - this._send({ + const response: IJsonRpcSuccessResponse = { jsonrpc: '2.0', id: request.id, result, - }); + }; + this._send(response); + return response; } catch (error) { + let response: IJsonRpcErrorResponse; if (error instanceof JsonRpcError) { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { @@ -226,17 +250,19 @@ export class JsonRpcProtocol extends Disposable { message: error.message, data: error.data, } - }); + }; } else { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.InternalError, message: error instanceof Error ? error.message : 'Internal error', } - }); + }; } + this._send(response); + return response; } finally { cts.dispose(true); } diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts index 630edb097f250..a75cbb1cce309 100644 --- a/src/vs/base/common/lifecycle.ts +++ b/src/vs/base/common/lifecycle.ts @@ -720,7 +720,7 @@ export class AsyncReferenceCollection { constructor(private referenceCollection: ReferenceCollection>) { } - async acquire(key: string, ...args: any[]): Promise> { + async acquire(key: string, ...args: unknown[]): Promise> { const ref = this.referenceCollection.acquire(key, ...args); try { diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 74cb106fd3cef..c2efd167054a8 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -75,6 +75,9 @@ export namespace Schemas { export const vscodeTerminal = 'vscode-terminal'; + /** Scheme used for the image carousel editor. */ + export const vscodeImageCarousel = 'vscode-image-carousel'; + /** Scheme used for code blocks in chat. */ export const vscodeChatCodeBlock = 'vscode-chat-code-block'; diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index af010d118c3b6..95347c3088b3e 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -7,7 +7,7 @@ export { observableValueOpts } from './observables/observableValueOpts.js'; export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta, autorunSelfDisposable } from './reactions/autorun.js'; -export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction } from './base.js'; +export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type IReaderWithStore, type ISettableObservable, type ITransaction } from './base.js'; export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; export { type IDerivedReader } from './observables/derivedImpl.js'; diff --git a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts index d19da1fe15970..c4d392bca69fd 100644 --- a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts +++ b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts @@ -72,7 +72,7 @@ export class SimpleTypedRpcConnection { const requests = new Proxy({}, { get: (target, key: string) => { - return async (...args: any[]) => { + return async (...args: unknown[]) => { const result = await this._channel.sendRequest([key, args] satisfies OutgoingMessage); if (result.type === 'error') { throw result.value; @@ -85,7 +85,7 @@ export class SimpleTypedRpcConnection { const notifications = new Proxy({}, { get: (target, key: string) => { - return (...args: any[]) => { + return (...args: unknown[]) => { this._channel.sendNotification([key, args] satisfies OutgoingMessage); }; } diff --git a/src/vs/base/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/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/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/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..c2c70d3930304 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -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'; @@ -133,6 +132,9 @@ import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeM import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; +import { SandboxHelperChannelName } from '../../platform/sandbox/common/sandboxHelperIpc.js'; +import { ISandboxHelperService } from '../../platform/sandbox/common/sandboxHelperService.js'; +import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelperService.js'; import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; @@ -408,7 +410,11 @@ export class CodeApplication extends Disposable { // Mac only event: open new window when we get activated if (!hasVisibleWindows) { - await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + if ((process as INodeProcess).isEmbeddedApp || (this.environmentMainService.args['sessions'] && this.productService.quality !== 'stable')) { + await this.windowsMainService?.openSessionsWindow({ context: OpenContext.DOCK }); + } else { + await this.windowsMainService?.openEmptyWindow({ context: OpenContext.DOCK }); + } } }); @@ -1059,7 +1065,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 */)); @@ -1149,6 +1154,9 @@ export class CodeApplication extends Disposable { // Proxy Auth services.set(IProxyAuthService, new SyncDescriptor(ProxyAuthService)); + // Sandbox + services.set(ISandboxHelperService, new SyncDescriptor(SandboxHelperService)); + // MCP services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); @@ -1281,10 +1289,14 @@ export class CodeApplication extends Disposable { const externalTerminalChannel = ProxyChannel.fromService(accessor.get(IExternalTerminalMainService), disposables); mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel); + // Sandbox + const sandboxHelperChannel = ProxyChannel.fromService(accessor.get(ISandboxHelperService), disposables); + mainProcessElectronServer.registerChannel(SandboxHelperChannelName, 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 diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index e112b958d730e..7f5cd7c26e9b4 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -134,9 +134,7 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; -import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js'; -import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js'; -import { IBrowserViewGroupRemoteService, BrowserViewGroupRemoteService } from '../../../platform/browserView/node/browserViewGroupRemoteService.js'; +import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -404,10 +402,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); - // Playwright - services.set(IBrowserViewGroupRemoteService, new SyncDescriptor(BrowserViewGroupRemoteService)); - services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService)); - return new InstantiationService(services); } @@ -476,7 +470,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); // Playwright - const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store); + const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService))); this.server.registerChannel('playwright', playwrightChannel); } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 8e29f4924766b..5f50659ff46c2 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, spawn, SpawnOptions, StdioOptions } from 'child_process'; -import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync } from 'fs'; +import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync, promises } from 'fs'; import { homedir, tmpdir } from 'os'; import type { ProfilingSession, Target } from 'v8-inspect-profiler'; import { Event } from '../../base/common/event.js'; @@ -499,8 +499,26 @@ export async function main(argv: string[]): Promise { // This way, Mac does not automatically try to foreground the new instance, which causes // focusing issues when the new instance only sends data to a previous instance and then closes. const spawnArgs = ['-n', '-g']; - // -a opens the given application. - spawnArgs.push('-a', process.execPath); // -a: opens a specific application + + // Figure out the app to launch: with --sessions we try to launch the embedded app + let appToLaunch = process.execPath; + if (args.sessions) { + // process.execPath is e.g. /Applications/Code.app/Contents/MacOS/Electron + // Embedded app is at /Applications/Code.app/Contents/Applications/.app + const contentsPath = dirname(dirname(process.execPath)); + const applicationsPath = join(contentsPath, 'Applications'); + try { + const files = await promises.readdir(applicationsPath); + const embeddedApp = files.find(file => file.endsWith('.app')); + if (embeddedApp) { + appToLaunch = join(applicationsPath, embeddedApp); + argv = argv.filter(arg => arg !== '--sessions'); + } + } catch (error) { + /* may not exist on disk */ + } + } + spawnArgs.push('-a', appToLaunch); // -a opens the given application. if (args.verbose || args.status) { spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running) diff --git a/src/vs/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..cdd1923cbe6c6 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -8,11 +8,10 @@ import { CharCode } from '../../../base/common/charCode.js'; 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 { EditorOption, IComputedEditorOptions, WrappingIndent } from '../../common/config/editorOptions.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'; const ttPolicy = createTrustedTypesPolicy('domLineBreaksComputer', { createHTML: value => value }); @@ -26,26 +25,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, options: IComputedEditorOptions, tabSize: number): 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, options, tabSize); } }; } } -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[], options: IComputedEditorOptions, tabSize: number): (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); @@ -57,11 +55,14 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo return null; } } - + const wrappingIndent = options.get(EditorOption.wrappingIndent); + const fontInfo = options.get(EditorOption.fontInfo); + const wordBreak = options.get(EditorOption.wordBreak); + const firstLineBreakColumn = options.get(EditorOption.wrappingInfo).wrappingColumn; 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 +81,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 +148,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 +175,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); 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..dc661c4b25a46 100644 --- a/src/vs/editor/common/modelLineProjectionData.ts +++ b/src/vs/editor/common/modelLineProjectionData.ts @@ -4,8 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { assertNever } from '../../base/common/assert.js'; -import { WrappingIndent } from './config/editorOptions.js'; -import { FontInfo } from './config/fontInfo.js'; +import { IComputedEditorOptions } from './config/editorOptions.js'; import { Position } from './core/position.js'; import { InjectedTextCursorStops, InjectedTextOptions, PositionAffinity } from './model.js'; import { LineInjectedText } from './textModelEvents.js'; @@ -328,14 +327,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, options: IComputedEditorOptions, tabSize: number): 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..58d52910d2e2c 100644 --- a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts +++ b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts @@ -7,10 +7,9 @@ import { CharCode } from '../../../base/common/charCode.js'; import * as strings from '../../../base/common/strings.js'; import { WrappingIndent, IComputedEditorOptions, EditorOption } from '../config/editorOptions.js'; 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 +25,27 @@ 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, options: IComputedEditorOptions, tabSize: number): 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 fontInfo = options.get(EditorOption.fontInfo); + const wrappingColumn = options.get(EditorOption.wrappingInfo).wrappingColumn; + const wrappingIndent = options.get(EditorOption.wrappingIndent); + const wordBreak = options.get(EditorOption.wordBreak); + const wrapOnEscapedLineFeeds = options.get(EditorOption.wrapOnEscapedLineFeeds); 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..569ab17725338 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'; @@ -97,25 +97,13 @@ export class ViewModel extends Disposable implements IViewModel { } else { const options = this._configuration.options; - const fontInfo = options.get(EditorOption.fontInfo); - const wrappingStrategy = options.get(EditorOption.wrappingStrategy); - const wrappingInfo = options.get(EditorOption.wrappingInfo); - const wrappingIndent = options.get(EditorOption.wrappingIndent); - const wordBreak = options.get(EditorOption.wordBreak); - const wrapOnEscapedLineFeeds = options.get(EditorOption.wrapOnEscapedLineFeeds); - this._lines = new ViewModelLinesFromProjectedModel( this._editorId, this.model, domLineBreaksComputerFactory, monospaceLineBreaksComputerFactory, - fontInfo, - this.model.getOptions().tabSize, - wrappingStrategy, - wrappingInfo.wrappingColumn, - wrappingIndent, - wordBreak, - wrapOnEscapedLineFeeds + options, + this.model.getOptions().tabSize ); } @@ -184,8 +172,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 { @@ -274,13 +262,8 @@ export class ViewModel extends Disposable implements IViewModel { private _onConfigurationChanged(eventsCollector: ViewModelEventsCollector, e: ConfigurationChangedEvent): void { const stableViewport = this._captureStableViewport(); const options = this._configuration.options; - const fontInfo = options.get(EditorOption.fontInfo); - const wrappingStrategy = options.get(EditorOption.wrappingStrategy); - const wrappingInfo = options.get(EditorOption.wrappingInfo); - const wrappingIndent = options.get(EditorOption.wrappingIndent); - const wordBreak = options.get(EditorOption.wordBreak); - if (this._lines.setWrappingSettings(fontInfo, wrappingStrategy, wrappingInfo.wrappingColumn, wrappingIndent, wordBreak)) { + if (this._lines.setWrappingSettings(options)) { eventsCollector.emitViewEvent(new viewEvents.ViewFlushedEvent()); eventsCollector.emitViewEvent(new viewEvents.ViewLineMappingChangedEvent()); eventsCollector.emitViewEvent(new viewEvents.ViewDecorationsChangedEvent(null)); @@ -332,22 +315,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 +329,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 +349,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 +375,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 +395,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..cc0490d815870 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -3,32 +3,30 @@ * 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'; +import { EditorOption, IComputedEditorOptions } from '../config/editorOptions.js'; import { IPosition, Position } from '../core/position.js'; 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; - setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): boolean; + setWrappingSettings(options: IComputedEditorOptions): boolean; setTabSize(newTabSize: number): boolean; 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; @@ -66,13 +64,8 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { private readonly _domLineBreaksComputerFactory: ILineBreaksComputerFactory; private readonly _monospaceLineBreaksComputerFactory: ILineBreaksComputerFactory; - private fontInfo: FontInfo; + private options: IComputedEditorOptions; private tabSize: number; - private wrappingColumn: number; - private wrappingIndent: WrappingIndent; - private wordBreak: 'normal' | 'keepAll'; - private wrappingStrategy: 'simple' | 'advanced'; - private wrapOnEscapedLineFeeds: boolean; private modelLineProjections!: IModelLineProjection[]; @@ -88,26 +81,16 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { model: ITextModel, domLineBreaksComputerFactory: ILineBreaksComputerFactory, monospaceLineBreaksComputerFactory: ILineBreaksComputerFactory, - fontInfo: FontInfo, + options: IComputedEditorOptions, tabSize: number, - wrappingStrategy: 'simple' | 'advanced', - wrappingColumn: number, - wrappingIndent: WrappingIndent, - wordBreak: 'normal' | 'keepAll', - wrapOnEscapedLineFeeds: boolean ) { this._editorId = editorId; this.model = model; this._validModelVersionId = -1; this._domLineBreaksComputerFactory = domLineBreaksComputerFactory; this._monospaceLineBreaksComputerFactory = monospaceLineBreaksComputerFactory; - this.fontInfo = fontInfo; + this.options = options; this.tabSize = tabSize; - this.wrappingStrategy = wrappingStrategy; - this.wrappingColumn = wrappingColumn; - this.wrappingIndent = wrappingIndent; - this.wordBreak = wordBreak; - this.wrapOnEscapedLineFeeds = wrapOnEscapedLineFeeds; this._constructLines(/*resetHiddenAreas*/true, null); } @@ -128,14 +111,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(); @@ -278,23 +258,19 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { return true; } - public setWrappingSettings(fontInfo: FontInfo, wrappingStrategy: 'simple' | 'advanced', wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): boolean { - const equalFontInfo = this.fontInfo.equals(fontInfo); - const equalWrappingStrategy = (this.wrappingStrategy === wrappingStrategy); - const equalWrappingColumn = (this.wrappingColumn === wrappingColumn); - const equalWrappingIndent = (this.wrappingIndent === wrappingIndent); - const equalWordBreak = (this.wordBreak === wordBreak); - if (equalFontInfo && equalWrappingStrategy && equalWrappingColumn && equalWrappingIndent && equalWordBreak) { + public setWrappingSettings(options: IComputedEditorOptions): boolean { + const equalFontInfo = this.options.get(EditorOption.fontInfo).equals(options.get(EditorOption.fontInfo)); + const equalWrappingStrategy = this.options.get(EditorOption.wrappingStrategy) === options.get(EditorOption.wrappingStrategy); + const equalWrappingInfo = this.options.get(EditorOption.wrappingInfo) === options.get(EditorOption.wrappingInfo); + const equalWrappingIndent = this.options.get(EditorOption.wrappingIndent) === options.get(EditorOption.wrappingIndent); + const equalWordBreak = this.options.get(EditorOption.wordBreak) === options.get(EditorOption.wordBreak); + if (equalFontInfo && equalWrappingStrategy && equalWrappingInfo && equalWrappingIndent && equalWordBreak) { return false; } - const onlyWrappingColumnChanged = (equalFontInfo && equalWrappingStrategy && !equalWrappingColumn && equalWrappingIndent && equalWordBreak); + const onlyWrappingColumnChanged = (equalFontInfo && equalWrappingStrategy && !equalWrappingInfo && equalWrappingIndent && equalWordBreak); - this.fontInfo = fontInfo; - this.wrappingStrategy = wrappingStrategy; - this.wrappingColumn = wrappingColumn; - this.wrappingIndent = wrappingIndent; - this.wordBreak = wordBreak; + this.options = options; let previousLineBreaks: ((ModelLineProjectionData | null)[]) | null = null; if (onlyWrappingColumnChanged) { @@ -309,13 +285,21 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { return true; } - public createLineBreaksComputer(): ILineBreaksComputer { + public createLineBreaksComputer(_context?: ILineBreaksComputerContext): ILineBreaksComputer { const lineBreaksComputerFactory = ( - this.wrappingStrategy === 'advanced' + this.options.get(EditorOption.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.options, this.tabSize); } public onModelFlushed(): void { @@ -1146,14 +1130,14 @@ export class ViewModelLinesFromModelAsIs implements IViewModelLines { return false; } - public setWrappingSettings(_fontInfo: FontInfo, _wrappingStrategy: 'simple' | 'advanced', _wrappingColumn: number, _wrappingIndent: WrappingIndent): boolean { + public setWrappingSettings(options: IComputedEditorOptions): boolean { return false; } 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/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/inlineCompletions/browser/model/suggestWidgetAdapter.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts index dcd8adce63d10..549865663123b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts @@ -20,6 +20,7 @@ import { CompletionItem } from '../../../suggest/browser/suggest.js'; import { SuggestController } from '../../../suggest/browser/suggestController.js'; import { ObservableCodeEditor } from '../../../../browser/observableCodeEditor.js'; import { observableFromEvent } from '../../../../../base/common/observable.js'; +import { EditorOption } from '../../../../common/config/editorOptions.js'; export class SuggestWidgetAdaptor extends Disposable { private isSuggestWidgetVisible: boolean = false; @@ -149,6 +150,17 @@ export class SuggestWidgetAdaptor extends Disposable { return undefined; } + // When offWhenInlineCompletions is active, don't expose the selected + // suggest item to the inline completions model so that it does not + // trigger an inline completion request while the suggest widget is open + const quickSuggestions = this.editor.getOption(EditorOption.quickSuggestions); + if (typeof quickSuggestions === 'object' + && (quickSuggestions.other === 'offWhenInlineCompletions' + || quickSuggestions.comments === 'offWhenInlineCompletions' + || quickSuggestions.strings === 'offWhenInlineCompletions')) { + return undefined; + } + const focusedItem = suggestController.widget.value.getFocusedItem(); const position = this.editor.getPosition(); const model = this.editor.getModel(); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index f4b3115a52ad7..3f5c6ba28ceff 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -5,34 +5,34 @@ import { timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; -import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js'; -import { Position } from '../../../../common/core/position.js'; -import { ITextModel } from '../../../../common/model.js'; -import { IInlineCompletionChangeHint, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; -import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; -import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; import { autorun, derived } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js'; +import { IBulkEditService } from '../../../../browser/services/bulkEditService.js'; +import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js'; +import { TextEdit } from '../../../../common/core/edits/textEdit.js'; +import { Position } from '../../../../common/core/position.js'; +import { Range } from '../../../../common/core/range.js'; +import { PositionOffsetTransformer } from '../../../../common/core/text/positionToOffset.js'; +import { IInlineCompletionChangeHint, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from '../../../../common/languages.js'; +import { ITextModel } from '../../../../common/model.js'; import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; +import { IModelService } from '../../../../common/services/model.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../common/services/resolverService.js'; import { ViewModel } from '../../../../common/viewModel/viewModelImpl.js'; +import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { InlineCompletionsController } from '../../browser/controller/inlineCompletionsController.js'; -import { Range } from '../../../../common/core/range.js'; -import { TextEdit } from '../../../../common/core/edits/textEdit.js'; -import { BugIndicatingError } from '../../../../../base/common/errors.js'; -import { PositionOffsetTransformer } from '../../../../common/core/text/positionToOffset.js'; +import { InlineCompletionsModel } from '../../browser/model/inlineCompletionsModel.js'; import { InlineSuggestionsView } from '../../browser/view/inlineSuggestionsView.js'; -import { IBulkEditService } from '../../../../browser/services/bulkEditService.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js'; -import { ITextModelService, IResolvedTextEditorModel } from '../../../../common/services/resolverService.js'; -import { IModelService } from '../../../../common/services/model.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -274,6 +274,8 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( 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/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/stickyScroll/browser/stickyScroll.css b/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css index ecc59245decf8..1cd84683e709c 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css @@ -7,7 +7,7 @@ overflow: hidden; border-bottom: 1px solid var(--vscode-editorStickyScroll-border); width: 100%; - box-shadow: var(--vscode-editorStickyScroll-shadow) 0 4px 2px -2px; + box-shadow: var(--vscode-shadow-md); z-index: 4; right: initial !important; margin-left: '0px'; diff --git a/src/vs/editor/contrib/suggest/browser/media/suggest.css b/src/vs/editor/contrib/suggest/browser/media/suggest.css index 755c457fc20bd..70f27a8fa869a 100644 --- a/src/vs/editor/contrib/suggest/browser/media/suggest.css +++ b/src/vs/editor/contrib/suggest/browser/media/suggest.css @@ -10,7 +10,8 @@ z-index: 40; display: flex; flex-direction: column; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .suggest-widget.message { @@ -96,6 +97,7 @@ .monaco-editor .suggest-widget .monaco-list { user-select: none; -webkit-user-select: none; + border-radius: var(--vscode-cornerRadius-large); } /** Styles for each row in the list element **/ diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index 30c1276d5c4fd..595b7ef51c9f3 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimeoutTimer } from '../../../../base/common/async.js'; +import { TimeoutTimer, disposableTimeout } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -30,8 +30,10 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; import { assertType } from '../../../../base/common/types.js'; import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; +import { getInlineCompletionsController } from '../../inlineCompletions/browser/controller/common.js'; import { SnippetController2 } from '../../snippet/browser/snippetController2.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { autorun } from '../../../../base/common/observable.js'; export interface ICancelEvent { readonly retrigger: boolean; @@ -134,6 +136,7 @@ export class SuggestModel implements IDisposable { private readonly _toDispose = new DisposableStore(); private readonly _triggerCharacterListener = new DisposableStore(); private readonly _triggerQuickSuggest = new TimeoutTimer(); + private _waitForInlineCompletions: DisposableStore | undefined; private _triggerState: SuggestTriggerOptions | undefined = undefined; private _requestToken?: CancellationTokenSource; @@ -209,6 +212,7 @@ export class SuggestModel implements IDisposable { dispose(): void { dispose(this._triggerCharacterListener); dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]); + this._waitForInlineCompletions?.dispose(); this._toDispose.dispose(); this._completionDisposables.dispose(); this.cancel(); @@ -310,8 +314,11 @@ export class SuggestModel implements IDisposable { } cancel(retrigger: boolean = false): void { + this._triggerQuickSuggest.cancel(); + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + if (this._triggerState !== undefined) { - this._triggerQuickSuggest.cancel(); this._requestToken?.cancel(); this._requestToken = undefined; this._triggerState = undefined; @@ -391,6 +398,10 @@ export class SuggestModel implements IDisposable { this.cancel(); + // Cancel any in-flight wait for inline completions from a previous cycle + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + this._triggerQuickSuggest.cancelAndSet(() => { if (this._triggerState !== undefined) { return; @@ -409,16 +420,19 @@ export class SuggestModel implements IDisposable { return; } + let waitForInlineCompletions = false; if (!QuickSuggestionsOptions.isAllOn(config)) { // Check the type of the token that triggered this model.tokenization.tokenizeIfCheap(pos.lineNumber); const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' - || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { - return; - } + const value = QuickSuggestionsOptions.valueFor(config, tokenType); + if (value === 'off' || value === 'inline') { + return; + } + if (value === 'offWhenInlineCompletions') { + waitForInlineCompletions = this._languageFeaturesService.inlineCompletionsProvider.has(model) + && this._editor.getOption(EditorOption.inlineSuggest).enabled; } } @@ -431,12 +445,73 @@ export class SuggestModel implements IDisposable { return; } - // we made it till here -> trigger now - this.trigger({ auto: true }); + if (waitForInlineCompletions) { + // Wait for inline completions to resolve before deciding + this._waitForInlineCompletionsAndTrigger(model, pos); + } else { + this.trigger({ auto: true }); + } }, this._editor.getOption(EditorOption.quickSuggestionsDelay)); } + private _waitForInlineCompletionsAndTrigger(initialModel: ITextModel, initialPosition: Position): void { + const initialModelVersion = initialModel.getVersionId(); + const inlineController = getInlineCompletionsController(this._editor); + const inlineModel = inlineController?.model.get(); + if (!inlineModel) { + this.trigger({ auto: true }); + return; + } + + const state = inlineModel.state.get(); + if (state?.inlineSuggestion) { + // Inline completions are already showing - suppress + return; + } + + const store = new DisposableStore(); + this._waitForInlineCompletions = store; + + const triggerAndCleanUp = (doTrigger: boolean) => { + store.dispose(); + if (this._waitForInlineCompletions === store) { + this._waitForInlineCompletions = undefined; + } + if (this._triggerState !== undefined) { + return; + } + if (!doTrigger) { + return; + } + const currentModel = this._editor.getModel(); + const currentPosition = this._editor.getPosition(); + if (currentModel === initialModel + && currentModel.getVersionId() === initialModelVersion + && currentPosition?.equals(initialPosition) + && this._editor.hasWidgetFocus() + ) { + this.trigger({ auto: true }); + } + }; + + // Race: observe inline completions state vs 750ms timeout + disposableTimeout(() => { + triggerAndCleanUp(true); + inlineModel.stop('automatic'); + }, 750, store); + + store.add(autorun(reader => { + const status = inlineModel.status.read(reader); + const currentState = inlineModel.state.read(reader); + if (!currentState && status === 'loading') { + // Still loading + return; + } + triggerAndCleanUp(!currentState); + })); + } + private _refilterCompletionItems(): void { assertType(this._editor.hasModel()); assertType(this._triggerState !== undefined); diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index ff465706c0c28..d99d63f7c9836 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -25,7 +25,7 @@ import { SuggestController } from '../../browser/suggestController.js'; import { ISuggestMemoryService } from '../../browser/suggestMemory.js'; import { LineContext, SuggestModel } from '../../browser/suggestModel.js'; import { ISelectedSuggestion } from '../../browser/suggestWidget.js'; -import { createTestCodeEditor, ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -42,6 +42,15 @@ import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browse import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js'; +import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IMenuService, IMenu } from '../../../../../platform/actions/common/actions.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IEditorWorkerService } from '../../../../common/services/editorWorker.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1230,11 +1239,11 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + test('offWhenInlineCompletions - allows quick suggest when inline provider returns empty results', function () { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - // Register a dummy inline completions provider + // Register a dummy inline completions provider that returns no items const inlineProvider: InlineCompletionsProvider = { provideInlineCompletions: () => ({ items: [] }), disposeInlineCompletions: () => { } @@ -1244,20 +1253,12 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { return withOracle((suggestOracle, editor) => { editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); - return new Promise((resolve, reject) => { - const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { - unexpectedSuggestSub.dispose(); - reject(new Error('Quick suggestions should not have been triggered')); - }); - + // Without an InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - // Wait for the quick suggest delay to pass without triggering - setTimeout(() => { - unexpectedSuggestSub.dispose(); - resolve(); - }, 200); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); }); }); }); @@ -1336,7 +1337,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + test('string shorthand - "offWhenInlineCompletions" allows quick suggest when inline provider returns empty', function () { return runWithFakedTimers({ useFakeTimers: true }, () => { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); @@ -1347,24 +1348,202 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); return withOracle((suggestOracle, editor) => { - // Use string shorthand — applies to all token types + // Use string shorthand - applies to all token types editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); - return new Promise((resolve, reject) => { - const sub = suggestOracle.onDidSuggest(() => { - sub.dispose(); - reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); - }); - + // Without InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + }); + }); + }); + }); +}); - setTimeout(() => { - sub.dispose(); - resolve(); - }, 200); +suite('SuggestModel - offWhenInlineCompletions with InlineCompletionsController', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const completionProvider: CompletionItemProvider = { + _debugDisplayName: 'test', + provideCompletionItems(doc, pos): CompletionList { + const wordUntil = doc.getWordUntilPosition(pos); + return { + incomplete: false, + suggestions: [{ + label: doc.getWordUntilPosition(pos).word, + kind: CompletionItemKind.Property, + insertText: 'foofoo', + range: new Range(pos.lineNumber, wordUntil.startColumn, pos.lineNumber, wordUntil.endColumn) + }] + }; + } + }; + + async function withSuggestModelAndInlineCompletions( + text: string, + inlineProvider: InlineCompletionsProvider, + callback: (suggestModel: SuggestModel, editor: ITestCodeEditor) => Promise, + ): Promise { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const disposableStore = new DisposableStore(); + try { + const languageFeaturesService = new LanguageFeaturesService(); + disposableStore.add(languageFeaturesService.completionProvider.register({ pattern: '**' }, completionProvider)); + disposableStore.add(languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, inlineProvider)); + + const serviceCollection = new ServiceCollection( + [ILanguageFeaturesService, languageFeaturesService], + [ITelemetryService, NullTelemetryService], + [ILogService, new NullLogService()], + [IStorageService, disposableStore.add(new InMemoryStorageService())], + [IKeybindingService, new MockKeybindingService()], + [IEditorWorkerService, new class extends mock() { + override computeWordRanges() { + return Promise.resolve({}); + } + }], + [ISuggestMemoryService, new class extends mock() { + override memorize(): void { } + override select(): number { return 0; } + }], + [IMenuService, new class extends mock() { + override createMenu() { + return new class extends mock() { + override onDidChange = Event.None; + override dispose() { } + }; + } + }], + [ILabelService, new class extends mock() { }], + [IWorkspaceContextService, new class extends mock() { }], + [IEnvironmentService, new class extends mock() { + override isBuilt: boolean = true; + override isExtensionDevelopment: boolean = false; + }], + [IAccessibilitySignalService, new class extends mock() { + override async playSignal() { } + override isSoundEnabled() { return false; } + }], + [IDefaultAccountService, new class extends mock() { + override onDidChangeDefaultAccount = Event.None; + override getDefaultAccount = async () => null; + override setDefaultAccountProvider = () => { }; + }], + ); + + await withAsyncTestCodeEditor(text, { serviceCollection }, async (editor, _editorViewModel, instantiationService) => { + instantiationService.stubInstance(InlineSuggestionsView, { + dispose: () => { } + }); + editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2); + editor.registerAndInstantiateContribution(InlineCompletionsController.ID, InlineCompletionsController); + + editor.hasWidgetFocus = () => true; + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + }); + + const suggestModel = disposableStore.add( + editor.invokeWithinContext(accessor => accessor.get(IInstantiationService).createInstance(SuggestModel, editor)) + ); + + await callback(suggestModel, editor); }); + } finally { + disposableStore.dispose(); + ModifierKeyEmitter.disposeInstance(); + } + }); + } + + test('suppresses quick suggest when inline completions are showing ghost text', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + // Return a completion that extends the current word - must be visible at cursor + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(() => { didSuggest = true; }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, false, 'Quick suggestions should have been suppressed when inline completions are showing'); + }); + }); + + test('allows quick suggest when inline completions resolve with no results', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered after inline completions resolved empty'); + }); + }); + + test('allows quick suggest when inlineSuggest is disabled even with provider', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + editor.updateOptions({ inlineSuggest: { enabled: false } }); + + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered when inlineSuggest is disabled'); }); }); }); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 0824dfbb53337..e12a15076e4f9 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -3,106 +3,106 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './standaloneCodeEditorService.js'; -import './standaloneLayoutService.js'; +import '../../../platform/hover/browser/hoverService.js'; import '../../../platform/undoRedo/common/undoRedoService.js'; +import '../../browser/services/inlineCompletionsService.js'; import '../../common/services/languageFeatureDebounce.js'; -import '../../common/services/semanticTokensStylingService.js'; import '../../common/services/languageFeaturesService.js'; -import '../../../platform/hover/browser/hoverService.js'; -import '../../browser/services/inlineCompletionsService.js'; +import '../../common/services/semanticTokensStylingService.js'; +import './standaloneCodeEditorService.js'; +import './standaloneLayoutService.js'; -import * as strings from '../../../base/common/strings.js'; import * as dom from '../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; +import { onUnexpectedError } from '../../../base/common/errors.js'; import { Emitter, Event, IValueWithChangeEvent, ValueWithChangeEvent } from '../../../base/common/event.js'; -import { ResolvedKeybinding, KeyCodeChord, Keybinding, decodeKeybinding } from '../../../base/common/keybindings.js'; -import { IDisposable, IReference, ImmortalReference, toDisposable, DisposableStore, Disposable, combinedDisposable } from '../../../base/common/lifecycle.js'; +import { KeyCodeChord, Keybinding, ResolvedKeybinding, decodeKeybinding } from '../../../base/common/keybindings.js'; +import { Disposable, DisposableStore, IDisposable, IReference, ImmortalReference, combinedDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../base/common/map.js'; import { OS, isLinux, isMacintosh } from '../../../base/common/platform.js'; +import { basename } from '../../../base/common/resources.js'; import Severity from '../../../base/common/severity.js'; +import * as strings from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; -import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../browser/services/renameSymbolTrackerService.js'; -import { IBulkEditOptions, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../browser/services/bulkEditService.js'; -import { isDiffEditorConfigurationKey, isEditorConfigurationKey } from '../../common/config/editorConfigurationSchema.js'; -import { EditOperation, ISingleEditOperation } from '../../common/core/editOperation.js'; -import { IPosition, Position as Pos } from '../../common/core/position.js'; -import { Range } from '../../common/core/range.js'; -import { ITextModel, ITextSnapshot } from '../../common/model.js'; -import { IModelService } from '../../common/services/model.js'; -import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../common/services/resolverService.js'; -import { ITextResourceConfigurationService, ITextResourcePropertiesService, ITextResourceConfigurationChangeEvent } from '../../common/services/textResourceConfiguration.js'; -import { CommandsRegistry, ICommandEvent, ICommandHandler, ICommandService } from '../../../platform/commands/common/commands.js'; -import { IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationService, IConfigurationModel, IConfigurationValue, ConfigurationTarget } from '../../../platform/configuration/common/configuration.js'; -import { Configuration, ConfigurationModel, ConfigurationChangeEvent } from '../../../platform/configuration/common/configurationModels.js'; -import { IContextKeyService, ContextKeyExpression } from '../../../platform/contextkey/common/contextkey.js'; -import { IConfirmation, IConfirmationResult, IDialogService, IInputResult, IPrompt, IPromptResult, IPromptWithCustomCancel, IPromptResultWithCancel, IPromptWithDefaultCancel, IPromptBaseButton } from '../../../platform/dialogs/common/dialogs.js'; -import { createDecorator, IInstantiationService, ServiceIdentifier } from '../../../platform/instantiation/common/instantiation.js'; -import { AbstractKeybindingService } from '../../../platform/keybinding/common/abstractKeybindingService.js'; -import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../platform/keybinding/common/keybinding.js'; -import { KeybindingResolver } from '../../../platform/keybinding/common/keybindingResolver.js'; -import { IKeybindingItem, KeybindingsRegistry } from '../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ResolvedKeybindingItem } from '../../../platform/keybinding/common/resolvedKeybindingItem.js'; -import { USLayoutResolvedKeybinding } from '../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; -import { ILabelService, ResourceLabelFormatter, IFormatterChangeEvent, Verbosity } from '../../../platform/label/common/label.js'; -import { INotification, INotificationHandle, INotificationService, IPromptChoice, IPromptOptions, NoOpNotification, IStatusMessageOptions, INotificationSource, INotificationSourceFilter, NotificationsFilter, IStatusHandle } from '../../../platform/notification/common/notification.js'; -import { IProgressRunner, IEditorProgressService, IProgressService, IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressNotificationOptions, IProgressOptions, IProgressStep, IProgressWindowOptions } from '../../../platform/progress/common/progress.js'; -import { ITelemetryService, TelemetryLevel } from '../../../platform/telemetry/common/telemetry.js'; -import { ISingleFolderWorkspaceIdentifier, IWorkspaceIdentifier, IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, WorkbenchState, WorkspaceFolder, STANDALONE_EDITOR_WORKSPACE_ID } from '../../../platform/workspace/common/workspace.js'; -import { ILayoutService } from '../../../platform/layout/browser/layoutService.js'; -import { StandaloneServicesNLS } from '../../common/standaloneStrings.js'; -import { basename } from '../../../base/common/resources.js'; -import { ICodeEditorService } from '../../browser/services/codeEditorService.js'; -import { ConsoleLogger, ILoggerService, ILogService, NullLoggerService } from '../../../platform/log/common/log.js'; -import { IWorkspaceTrustManagementService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo } from '../../../platform/workspace/common/workspaceTrust.js'; -import { EditorOption } from '../../common/config/editorOptions.js'; -import { ICodeEditor, IDiffEditor } from '../../browser/editorBrowser.js'; -import { IContextMenuService, IContextViewDelegate, IContextViewService, IOpenContextView } from '../../../platform/contextview/browser/contextView.js'; -import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; -import { LanguageService } from '../../common/services/languageService.js'; -import { ContextMenuService } from '../../../platform/contextview/browser/contextMenuService.js'; -import { getSingletonServiceDescriptors, InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; -import { OpenerService } from '../../browser/services/openerService.js'; -import { ILanguageService } from '../../common/languages/language.js'; -import { MarkerDecorationsService } from '../../common/services/markerDecorationsService.js'; -import { IMarkerDecorationsService } from '../../common/services/markerDecorations.js'; -import { ModelService } from '../../common/services/modelService.js'; -import { StandaloneQuickInputService } from './quickInput/standaloneQuickInputService.js'; -import { StandaloneThemeService } from './standaloneThemeService.js'; -import { IStandaloneThemeService } from '../common/standaloneTheme.js'; import { AccessibilityService } from '../../../platform/accessibility/browser/accessibilityService.js'; import { IAccessibilityService } from '../../../platform/accessibility/common/accessibility.js'; +import { AccessibilityModality, AccessibilitySignal, IAccessibilitySignalService, Sound } from '../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IMenuService } from '../../../platform/actions/common/actions.js'; import { MenuService } from '../../../platform/actions/common/menuService.js'; import { BrowserClipboardService } from '../../../platform/clipboard/browser/clipboardService.js'; import { IClipboardService } from '../../../platform/clipboard/common/clipboardService.js'; +import { CommandsRegistry, ICommandEvent, ICommandHandler, ICommandService } from '../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationData, IConfigurationModel, IConfigurationOverrides, IConfigurationService, IConfigurationValue } from '../../../platform/configuration/common/configuration.js'; +import { Configuration, ConfigurationChangeEvent, ConfigurationModel } from '../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration } from '../../../platform/configuration/common/configurations.js'; import { ContextKeyService } from '../../../platform/contextkey/browser/contextKeyService.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { ContextMenuService } from '../../../platform/contextview/browser/contextMenuService.js'; +import { IContextMenuService, IContextViewDelegate, IContextViewService, IOpenContextView } from '../../../platform/contextview/browser/contextView.js'; +import { ContextViewService } from '../../../platform/contextview/browser/contextViewService.js'; +import { IDataChannelService, NullDataChannelService } from '../../../platform/dataChannel/common/dataChannel.js'; +import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; +import { IConfirmation, IConfirmationResult, IDialogService, IInputResult, IPrompt, IPromptBaseButton, IPromptResult, IPromptResultWithCancel, IPromptWithCustomCancel, IPromptWithDefaultCancel } from '../../../platform/dialogs/common/dialogs.js'; +import { ExtensionKind, IEnvironmentService, IExtensionHostDebugParams } from '../../../platform/environment/common/environment.js'; import { SyncDescriptor } from '../../../platform/instantiation/common/descriptors.js'; +import { InstantiationType, getSingletonServiceDescriptors, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServiceIdentifier, createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { InstantiationService } from '../../../platform/instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../platform/instantiation/common/serviceCollection.js'; +import { AbstractKeybindingService } from '../../../platform/keybinding/common/abstractKeybindingService.js'; +import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../platform/keybinding/common/keybinding.js'; +import { KeybindingResolver } from '../../../platform/keybinding/common/keybindingResolver.js'; +import { IKeybindingItem, KeybindingsRegistry } from '../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ResolvedKeybindingItem } from '../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { USLayoutResolvedKeybinding } from '../../../platform/keybinding/common/usLayoutResolvedKeybinding.js'; +import { IFormatterChangeEvent, ILabelService, ResourceLabelFormatter, Verbosity } from '../../../platform/label/common/label.js'; +import { ILayoutService } from '../../../platform/layout/browser/layoutService.js'; import { IListService, ListService } from '../../../platform/list/browser/listService.js'; +import { ConsoleLogger, ILogService, ILoggerService, NullLoggerService } from '../../../platform/log/common/log.js'; +import { LogService } from '../../../platform/log/common/logService.js'; import { IMarkerService } from '../../../platform/markers/common/markers.js'; import { MarkerService } from '../../../platform/markers/common/markerService.js'; +import { INotification, INotificationHandle, INotificationService, INotificationSource, INotificationSourceFilter, IPromptChoice, IPromptOptions, IStatusHandle, IStatusMessageOptions, NoOpNotification, NotificationsFilter } from '../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../platform/opener/common/opener.js'; +import { IEditorProgressService, IProgress, IProgressCompositeOptions, IProgressDialogOptions, IProgressNotificationOptions, IProgressOptions, IProgressRunner, IProgressService, IProgressStep, IProgressWindowOptions } from '../../../platform/progress/common/progress.js'; import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; import { IStorageService, InMemoryStorageService } from '../../../platform/storage/common/storage.js'; -import { DefaultConfiguration } from '../../../platform/configuration/common/configurations.js'; -import { WorkspaceEdit } from '../../common/languages.js'; -import { AccessibilitySignal, AccessibilityModality, IAccessibilitySignalService, Sound } from '../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { LogService } from '../../../platform/log/common/logService.js'; +import { ITelemetryService, TelemetryLevel } from '../../../platform/telemetry/common/telemetry.js'; +import { IUserInteractionService } from '../../../platform/userInteraction/browser/userInteractionService.js'; +import { UserInteractionService } from '../../../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; +import { ISingleFolderWorkspaceIdentifier, IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, IWorkspaceIdentifier, STANDALONE_EDITOR_WORKSPACE_ID, WorkbenchState, WorkspaceFolder } from '../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustTransitionParticipant, IWorkspaceTrustUriInfo } from '../../../platform/workspace/common/workspaceTrust.js'; +import { ICodeEditor, IDiffEditor } from '../../browser/editorBrowser.js'; +import { IBulkEditOptions, IBulkEditResult, IBulkEditService, ResourceEdit, ResourceTextEdit } from '../../browser/services/bulkEditService.js'; +import { ICodeEditorService } from '../../browser/services/codeEditorService.js'; +import { OpenerService } from '../../browser/services/openerService.js'; +import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../browser/services/renameSymbolTrackerService.js'; +import { isDiffEditorConfigurationKey, isEditorConfigurationKey } from '../../common/config/editorConfigurationSchema.js'; +import { EditorOption } from '../../common/config/editorOptions.js'; +import { EditOperation, ISingleEditOperation } from '../../common/core/editOperation.js'; +import { IPosition, Position as Pos } from '../../common/core/position.js'; +import { Range } from '../../common/core/range.js'; import { getEditorFeatures } from '../../common/editorFeatures.js'; -import { onUnexpectedError } from '../../../base/common/errors.js'; -import { ExtensionKind, IEnvironmentService, IExtensionHostDebugParams } from '../../../platform/environment/common/environment.js'; -import { mainWindow } from '../../../base/browser/window.js'; -import { ResourceMap } from '../../../base/common/map.js'; +import { WorkspaceEdit } from '../../common/languages.js'; +import { ILanguageService } from '../../common/languages/language.js'; +import { ITextModel, ITextSnapshot } from '../../common/model.js'; +import { LanguageService } from '../../common/services/languageService.js'; +import { IMarkerDecorationsService } from '../../common/services/markerDecorations.js'; +import { MarkerDecorationsService } from '../../common/services/markerDecorationsService.js'; +import { IModelService } from '../../common/services/model.js'; +import { ModelService } from '../../common/services/modelService.js'; +import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../common/services/resolverService.js'; +import { ITextResourceConfigurationChangeEvent, ITextResourceConfigurationService, ITextResourcePropertiesService } from '../../common/services/textResourceConfiguration.js'; import { ITreeSitterLibraryService } from '../../common/services/treeSitter/treeSitterLibraryService.js'; -import { StandaloneTreeSitterLibraryService } from './standaloneTreeSitterLibraryService.js'; -import { IDataChannelService, NullDataChannelService } from '../../../platform/dataChannel/common/dataChannel.js'; -import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; +import { StandaloneServicesNLS } from '../../common/standaloneStrings.js'; +import { IStandaloneThemeService } from '../common/standaloneTheme.js'; +import { StandaloneQuickInputService } from './quickInput/standaloneQuickInputService.js'; import { StandaloneWebWorkerService } from './services/standaloneWebWorkerService.js'; -import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; -import { IUserInteractionService } from '../../../platform/userInteraction/browser/userInteractionService.js'; -import { UserInteractionService } from '../../../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import { StandaloneThemeService } from './standaloneThemeService.js'; +import { StandaloneTreeSitterLibraryService } from './standaloneTreeSitterLibraryService.js'; class SimpleModel implements IResolvedTextEditorModel { @@ -852,6 +852,10 @@ class StandaloneWorkspaceContextService implements IWorkspaceContextService { return WorkbenchState.EMPTY; } + public hasWorkspaceData(): boolean { + return this.getWorkbenchState() !== WorkbenchState.EMPTY; + } + public getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { return resource && resource.scheme === StandaloneWorkspaceContextService.SCHEME ? this.workspace.folders[0] : null; } @@ -1119,6 +1123,8 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly onDidChangeDefaultAccount: Event = Event.None; readonly onDidChangePolicyData: Event = Event.None; readonly policyData: IPolicyData | null = null; + readonly copilotTokenInfo = null; + readonly onDidChangeCopilotTokenInfo: Event = Event.None; async getDefaultAccount(): Promise { return null; diff --git a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts index 511842715693f..f0f79d731d105 100644 --- a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts @@ -506,6 +506,114 @@ suite('Cursor move by blankline test', () => { }); }); +// Tests for 'foldedLine' unit: moves by model lines but treats each fold as a single step. +// This is the semantics required by vim's j/k: move through visible lines, skip hidden ones. + +suite('Cursor move command - foldedLine unit', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function executeFoldTest(callback: (editor: ITestCodeEditor, viewModel: ViewModel) => void): void { + withTestCodeEditor([ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + ].join('\n'), {}, (editor, viewModel) => { + callback(editor, viewModel); + }); + } + + test('move down by foldedLine skips a fold below the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 4 is hidden (folded under line 3 as header) + viewModel.setHiddenAreas([new Range(4, 1, 4, 1)]); + moveTo(viewModel, 2, 1); + // j from line 2 → line 3 (visible fold header) + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 3, 1); + // j from line 3 (fold header) → line 4 is hidden, lands on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine skips a fold above the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden (folded under line 2 as header) + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 4, 1); + // k from line 4: line 3 is hidden, lands on line 2 (fold header) + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 2, 1); + // k from line 2 → line 1 + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count treats each fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 1, 1); + // 3j from line 1: step1→2, step2→3(hidden)→4, step3→5 + moveDownByFoldedLine(viewModel, 3); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine skips a multi-line fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden (folded under line 1 as header) + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // j from line 1: lines 2-4 are all hidden, lands directly on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine at last line stays at last line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 5, 1); + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine at first line stays at first line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 1, 1); + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count clamps to last visible line after fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // 2j should land on line 5 and clamp there. + moveDownByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine with count clamps to first visible line before fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 5, 1); + // 2k should land on line 1 and clamp there. + moveUpByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 1, 1); + }); + }); +}); + // Move command function move(viewModel: ViewModel, args: any) { @@ -564,6 +672,14 @@ function moveDownByModelLine(viewModel: ViewModel, noOfLines: number = 1, select move(viewModel, { to: CursorMove.RawDirection.Down, value: noOfLines, select: select }); } +function moveDownByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Down, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + +function moveUpByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Up, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + function moveToTop(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { move(viewModel, { to: CursorMove.RawDirection.ViewPortTop, value: noOfLines, select: select }); } diff --git a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts index 05f1deb2aa098..dfe7d94313598 100644 --- a/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts +++ b/src/vs/editor/test/browser/viewModel/modelLineProjection.test.ts @@ -94,14 +94,9 @@ suite('Editor ViewModel - SplitLinesCollection', () => { }); function withSplitLinesCollection(text: string, callback: (model: TextModel, linesCollection: ViewModelLinesFromProjectedModel) => void): void { - const config = new TestConfiguration({}); - const wrappingInfo = config.options.get(EditorOption.wrappingInfo); - const fontInfo = config.options.get(EditorOption.fontInfo); + const config = new TestConfiguration({ wrappingStrategy: 'simple' }); const wordWrapBreakAfterCharacters = config.options.get(EditorOption.wordWrapBreakAfterCharacters); const wordWrapBreakBeforeCharacters = config.options.get(EditorOption.wordWrapBreakBeforeCharacters); - const wrappingIndent = config.options.get(EditorOption.wrappingIndent); - const wordBreak = config.options.get(EditorOption.wordBreak); - const wrapOnEscapedLineFeeds = config.options.get(EditorOption.wrapOnEscapedLineFeeds); const lineBreaksComputerFactory = new MonospaceLineBreaksComputerFactory(wordWrapBreakBeforeCharacters, wordWrapBreakAfterCharacters); const model = createTextModel(text); @@ -111,13 +106,8 @@ suite('Editor ViewModel - SplitLinesCollection', () => { model, lineBreaksComputerFactory, lineBreaksComputerFactory, - fontInfo, - model.getOptions().tabSize, - 'simple', - wrappingInfo.wrappingColumn, - wrappingIndent, - wordBreak, - wrapOnEscapedLineFeeds + config.options, + model.getOptions().tabSize ); callback(model, linesCollection); @@ -943,14 +933,11 @@ suite('SplitLinesCollection', () => { const configuration = new TestConfiguration({ wordWrap: wordWrap, wordWrapColumn: wordWrapColumn, - wrappingIndent: 'indent' + wrappingIndent: 'indent', + wrappingStrategy: 'simple' }); - const wrappingInfo = configuration.options.get(EditorOption.wrappingInfo); - const fontInfo = configuration.options.get(EditorOption.fontInfo); const wordWrapBreakAfterCharacters = configuration.options.get(EditorOption.wordWrapBreakAfterCharacters); const wordWrapBreakBeforeCharacters = configuration.options.get(EditorOption.wordWrapBreakBeforeCharacters); - const wrappingIndent = configuration.options.get(EditorOption.wrappingIndent); - const wordBreak = configuration.options.get(EditorOption.wordBreak); const lineBreaksComputerFactory = new MonospaceLineBreaksComputerFactory(wordWrapBreakBeforeCharacters, wordWrapBreakAfterCharacters); @@ -959,13 +946,8 @@ suite('SplitLinesCollection', () => { model, lineBreaksComputerFactory, lineBreaksComputerFactory, - fontInfo, - model.getOptions().tabSize, - 'simple', - wrappingInfo.wrappingColumn, - wrappingIndent, - wordBreak, - wrapOnEscapedLineFeeds + configuration.options, + model.getOptions().tabSize ); callback(linesCollection); diff --git a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts b/src/vs/editor/test/browser/viewModel/monospaceLineBreaksComputer.test.ts similarity index 94% rename from src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts rename to src/vs/editor/test/browser/viewModel/monospaceLineBreaksComputer.test.ts index bed861e44a1bf..7934cc6867461 100644 --- a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts +++ b/src/vs/editor/test/browser/viewModel/monospaceLineBreaksComputer.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { EditorOptions, WrappingIndent } from '../../../common/config/editorOptions.js'; +import { EditorOption, 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'; +import { ComputedEditorOptions } from '../../../browser/config/editorConfiguration.js'; function parseAnnotatedText(annotatedText: string): { text: string; indices: number[] } { let text = ''; @@ -63,9 +64,29 @@ 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 options = new ComputedEditorOptions(); + options._write(EditorOption.fontInfo, fontInfo); + options._write(EditorOption.wrappingIndent, wrappingIndent); + options._write(EditorOption.wordWrapColumn, breakAfter); + options._write(EditorOption.wordBreak, wordBreak); + options._write(EditorOption.wrapOnEscapedLineFeeds, wrapOnEscapedLineFeeds); + options._write(EditorOption.wrappingInfo, { + isDominatedByLongLines: false, + isWordWrapMinified: false, + isViewportWrapping: false, + wrappingColumn: breakAfter, + }); + const lineBreaksComputer = factory.createLineBreaksComputer(context, options, tabSize); 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/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/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..5a6cf722cccfb 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -10,7 +10,7 @@ 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, toAction } from '../../../base/common/actions.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; @@ -96,6 +96,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 { @@ -176,6 +181,7 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, + private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -297,11 +303,23 @@ 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 }); } } @@ -341,6 +359,11 @@ export interface IActionListOptions { */ readonly filterPlaceholder?: string; + /** + * Optional actions shown in the filter row, to the right of the input. + */ + readonly filterActions?: readonly IAction[]; + /** * Section IDs that should be collapsed by default. */ @@ -351,6 +374,12 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * When true, descriptions are rendered as subtext below the title + * instead of inline to the right. + */ + readonly descriptionBelow?: boolean; + /** @@ -365,11 +394,11 @@ export class ActionList extends Disposable { private readonly _list: List>; - private readonly _actionLineHeight = 24; + private readonly _actionLineHeight: number; private readonly _headerLineHeight = 24; private readonly _separatorLineHeight = 8; - private readonly _allMenuItems: readonly IActionListItem[]; + private _allMenuItems: IActionListItem[]; private readonly cts = this._register(new CancellationTokenSource()); @@ -413,6 +442,10 @@ export class ActionList extends Disposable { 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; // Initialize collapsed sections if (this._options?.collapsedByDefault) { @@ -437,7 +470,7 @@ export class ActionList extends Disposable { this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer>(preview, this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -482,19 +515,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; @@ -769,7 +810,8 @@ export class ActionList extends Disposable { availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; } - const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.6); + const maxHeight = Math.min(Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight), viewportMaxHeight); const height = Math.min(listHeight + filterHeight, maxHeight); return height - filterHeight; } @@ -815,7 +857,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,7 +876,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(this._list.element(i))); } } return Math.max(...itemWidths, effectiveMinWidth); @@ -1017,6 +1059,27 @@ 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)); diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 96fc6cbf50661..8957a85b01f88 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 { @@ -119,7 +119,7 @@ .action-widget .monaco-list-row.action { display: flex; - gap: 6px; + gap: 8px; align-items: center; } @@ -217,6 +217,31 @@ font-size: 12px; } +/* Description below mode — shows descriptions as subtext under the title */ +.action-widget .description-below .monaco-list .monaco-list-row.action { + flex-wrap: wrap; + align-content: center; + padding-top: 6px; + padding-right: 2px; + + .title { + line-height: 14px; + } + + .description { + display: block !important; + width: 100%; + margin-left: 0; + padding-left: 20px; + font-size: 11px; + line-height: 14px; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -240,7 +265,13 @@ /* 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; } .action-widget .action-list-filter:first-child { @@ -253,6 +284,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 +301,12 @@ .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); +} diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 2b2022fb43572..82e6ff581bad9 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -170,6 +170,7 @@ export class ActionWidgetDropdown extends BaseDropdown { action.run(); }, onHide: () => { + this.hide(); if (isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -221,6 +222,8 @@ export class ActionWidgetDropdown extends BaseDropdown { getWidgetRole: () => 'menu', }; + super.show(); + this.actionWidgetService.show( this._options.label ?? '', false, diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index a167319371ca0..e44cdb4eae07e 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -15,7 +15,7 @@ import { Iterable } from '../../../base/common/iterator.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { createActionViewItem, getActionBarActions } from './menuEntryActionViewItem.js'; -import { IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; import { createConfigureKeybindingAction } from '../common/menuService.js'; import { ICommandService } from '../../commands/common/commands.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; @@ -184,7 +184,8 @@ export class WorkbenchToolBar extends ToolBar { // coalesce turns Array into IAction[] coalesceInPlace(primary); coalesceInPlace(extraSecondary); - super.setActions(primary, Separator.join(extraSecondary, secondary)); + + super.setActions(Separator.clean(primary), Separator.join(extraSecondary, secondary)); // add context menu for toggle and configure keybinding actions if (toggleActions.length > 0 || primary.length > 0) { @@ -332,6 +333,11 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { private readonly _onDidChangeMenuItems = this._store.add(new Emitter()); get onDidChangeMenuItems() { return this._onDidChangeMenuItems.event; } + private readonly _menu: IMenu; + private readonly _menuOptions: IMenuActionOptions | undefined; + private readonly _toolbarOptions: IToolBarRenderOptions | undefined; + private readonly _container: HTMLElement; + constructor( container: HTMLElement, menuId: MenuId, @@ -361,30 +367,44 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { } }, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + this._container = container; + this._menuOptions = options?.menuOptions; + this._toolbarOptions = options?.toolbarOptions; + // update logic - const menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); - const updateToolbar = () => { - const { primary, secondary } = getActionBarActions( - menu.getActions(options?.menuOptions), - options?.toolbarOptions?.primaryGroup, - options?.toolbarOptions?.shouldInlineSubmenu, - options?.toolbarOptions?.useSeparatorsInPrimaryActions - ); - container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); - super.setActions(primary, secondary); - }; - - this._store.add(menu.onDidChange(() => { - updateToolbar(); + this._menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); + + this._store.add(this._menu.onDidChange(() => { + this._updateToolbar(); this._onDidChangeMenuItems.fire(this); })); this._store.add(actionViewService.onDidChange(e => { if (e === menuId) { - updateToolbar(); + this._updateToolbar(); } })); - updateToolbar(); + this._updateToolbar(); + } + + private _updateToolbar(): void { + const { primary, secondary } = getActionBarActions( + this._menu.getActions(this._menuOptions), + this._toolbarOptions?.primaryGroup, + this._toolbarOptions?.shouldInlineSubmenu, + this._toolbarOptions?.useSeparatorsInPrimaryActions + ); + this._container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); + super.setActions(primary, secondary); + } + + /** + * Force the toolbar to immediately re-evaluate its menu actions. + * Use this after synchronously updating context keys to avoid + * layout shifts caused by the debounced menu change event. + */ + refresh(): void { + this._updateToolbar(); } /** diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1b3e9d595c887..5c1cf404a4c3a 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'); @@ -255,10 +256,13 @@ 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 ChatEditingSessionChangesVersionsSubmenu = new MenuId('ChatEditingSessionChangesVersionsSubmenu'); static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent'); static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk'); static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell'); 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..2973512713422 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; diff --git a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts index 5e018a6d718a2..84a64f6d43bfc 100644 --- a/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts +++ b/src/vs/platform/browserElements/electron-main/nativeBrowserElementsMainService.ts @@ -431,6 +431,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..a11d7b88e4802 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -6,6 +6,30 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; + +const commandPrefix = 'workbench.action.browser'; +export enum BrowserViewCommandId { + Open = `${commandPrefix}.open`, + NewTab = `${commandPrefix}.newTab`, + GoBack = `${commandPrefix}.goBack`, + GoForward = `${commandPrefix}.goForward`, + Reload = `${commandPrefix}.reload`, + HardReload = `${commandPrefix}.hardReload`, + FocusUrlInput = `${commandPrefix}.focusUrlInput`, + AddElementToChat = `${commandPrefix}.addElementToChat`, + AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + ToggleDevTools = `${commandPrefix}.toggleDevTools`, + OpenExternal = `${commandPrefix}.openExternal`, + ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, + ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, + ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, + OpenSettings = `${commandPrefix}.openSettings`, + ShowFind = `${commandPrefix}.showFind`, + HideFind = `${commandPrefix}.hideFind`, + FindNext = `${commandPrefix}.findNext`, + FindPrevious = `${commandPrefix}.findPrevious`, +} export interface IBrowserViewBounds { windowId: number; @@ -14,6 +38,7 @@ export interface IBrowserViewBounds { width: number; height: number; zoomFactor: number; + cornerRadius: number; } export interface IBrowserViewCaptureScreenshotOptions { @@ -34,6 +59,7 @@ export interface IBrowserViewState { lastFavicon: string | undefined; lastError: IBrowserViewLoadError | undefined; storageScope: BrowserViewStorageScope; + browserZoomIndex: number; } export interface IBrowserViewNavigationEvent { @@ -118,6 +144,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 +192,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 +242,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. @@ -276,4 +324,13 @@ 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; + + /** + * 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..66853e50999a2 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -9,6 +9,8 @@ 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' diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index 85dc5f6d52d84..86b3f4af1a50d 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ICDPTarget, CDPEvent, CDPError, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; +import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; /** * CDP protocol handler for browser-level connections. @@ -95,22 +95,29 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { for (const target of this.browserTarget.getTargets()) { void this._targets.register(target); } + + // Mirror typed events to the onMessage channel + this._register(this._onEvent.event(event => { + this._onMessage.fire(event); + })); } // #region Public API - // Events to external client (ICDPConnection) + // Events to external clients private readonly _onEvent = this._register(new Emitter()); readonly onEvent: Event = this._onEvent.event; private readonly _onClose = this._register(new Emitter()); readonly onClose: Event = this._onClose.event; + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage: Event = this._onMessage.event; /** - * Send a CDP message and await the result. + * Send a CDP command and await the result. * Browser-level handlers (Browser.*, Target.*) are checked first. * Other commands are routed to the page session identified by sessionId. */ - async sendMessage(method: string, params: unknown = {}, sessionId?: string): Promise { + async sendCommand(method: string, params: unknown = {}, sessionId?: string): Promise { try { // Browser-level command handling if ( @@ -131,7 +138,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { throw new CDPServerError(`Session not found: ${sessionId}`); } - const result = await connection.sendMessage(method, params); + const result = await connection.sendCommand(method, params); return result ?? {}; } catch (error) { if (error instanceof CDPError) { @@ -141,6 +148,27 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } } + /** + * Accept a CDP request from a message-based transport (WebSocket, IPC, etc.), route it, + * and deliver the response or error via {@link onMessage}. + */ + async sendMessage({ id, method, params, sessionId }: CDPRequest): Promise { + return this.sendCommand(method, params, sessionId) + .then(result => { + this._onMessage.fire({ id, result, sessionId }); + }) + .catch((error: Error) => { + this._onMessage.fire({ + id, + error: { + code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, + message: error.message || 'Unknown error' + }, + sessionId + }); + }); + } + // #endregion // #region CDP Commands @@ -206,7 +234,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } private async handleTargetGetTargets() { - return { targetInfos: this._targets.getAllInfos() }; + return { targetInfos: Array.from(this._targets.getAllInfos()) }; } private async handleTargetGetTargetInfo({ targetId }: { targetId?: string } = {}) { diff --git a/src/vs/platform/browserView/common/cdp/types.ts b/src/vs/platform/browserView/common/cdp/types.ts index 603467e3ed2df..6fbfd30e26f9b 100644 --- a/src/vs/platform/browserView/common/cdp/types.ts +++ b/src/vs/platform/browserView/common/cdp/types.ts @@ -151,7 +151,7 @@ export interface ICDPBrowserTarget extends ICDPTarget { /** Get all available targets */ getTargets(): IterableIterator; /** Create a new target in the specified browser context */ - createTarget(url: string, browserContextId?: string): Promise; + createTarget(url: string, browserContextId?: string, windowId?: number): Promise; /** Activate a target (bring to foreground) */ activateTarget(target: ICDPTarget): Promise; /** Close a target */ @@ -187,5 +187,5 @@ export interface ICDPConnection extends IDisposable { * @param sessionId Optional session ID for targeting a specific session * @returns Promise resolving to the result or rejecting with a CDPError */ - sendMessage(method: string, params?: unknown, sessionId?: string): Promise; + sendCommand(method: string, params?: unknown, sessionId?: string): Promise; } diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index b49c50b5fb388..1615924a5cfe6 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); @@ -63,23 +62,24 @@ export interface IPlaywrightService { getSummary(pageId: string): Promise; /** - * Run a function with access to a Playwright page. + * Run a function with access to a Playwright page and return its raw result, or throw an error. * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. * @param pageId The browser view ID identifying the page to operate on. * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. * @param args Additional arguments to pass to the function after the `page` object. - * @returns The result of the function execution, including a page summary. + * @returns The result of the function execution. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise; /** - * Takes a screenshot of the current page viewport and returns it as a VSBuffer. - * @param pageId The browser view ID identifying the page to capture. - * @param selector Optional Playwright selector to capture a specific element instead of the viewport. - * @param fullPage Whether to capture the full scrollable page instead of just the viewport. - * @returns The screenshot image data. + * Run a function with access to a Playwright page and return a result for tool output, including error handling. + * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * @param pageId The browser view ID identifying the page to operate on. + * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. + * @param args Additional arguments to pass to the function after the `page` object. + * @returns The result of the function execution, including a page summary. */ - captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise; + invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; /** * Responds to a file chooser dialog on the given page. diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 45e5d838d277c..d8a2d96987c61 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -8,18 +8,19 @@ 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 { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.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'; +import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; +import { hasKey } from '../../../base/common/types.js'; /** Key combinations that are used in system-level shortcuts. */ const nativeShortcuts = new Set([ @@ -45,9 +46,10 @@ export class BrowserView extends Disposable implements ICDPTarget { private _lastFavicon: string | undefined = undefined; private _lastError: IBrowserViewLoadError | undefined = undefined; private _lastUserGestureTimestamp: number = -Infinity; + private _browserZoomIndex: number = browserZoomDefaultIndex; private _debugger: BrowserViewDebugger; - private _window: IBaseWindow | undefined; + private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isSendingKeyEvent = false; private _isDisposed = false; @@ -88,6 +90,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, @@ -150,6 +153,10 @@ export class BrowserView extends Disposable implements ICDPTarget { }; }); + this._view.webContents.on('context-menu', (_event, params) => { + openContextMenu(this, params); + }); + this._view.webContents.on('destroyed', () => { this.dispose(); }); @@ -272,6 +279,12 @@ export class BrowserView extends Disposable implements ICDPTarget { webContents.on('did-navigate', fireNavigationEvent); webContents.on('did-navigate-in-page', fireNavigationEvent); + // Chromium resets the zoom factor to its per-origin default (100%) when + // navigating to a new document. Re-apply our stored zoom to override it. + webContents.on('did-navigate', () => { + this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); + }); + // Focus events webContents.on('focus', () => { this._onDidChangeFocus.fire({ focused: true }); @@ -359,7 +372,8 @@ export class BrowserView extends Disposable implements ICDPTarget { lastScreenshot: this._lastScreenshot, lastFavicon: this._lastFavicon, lastError: this._lastError, - storageScope: this.session.storageScope + storageScope: this.session.storageScope, + browserZoomIndex: this._browserZoomIndex }; } @@ -375,7 +389,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 +397,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 +406,12 @@ export class BrowserView extends Disposable implements ICDPTarget { }); } + setBrowserZoomIndex(zoomIndex: number): void { + this._browserZoomIndex = Math.max(0, Math.min(zoomIndex, browserZoomFactors.length - 1)); + const browserZoomFactor = browserZoomFactors[this._browserZoomIndex]; + this._view.webContents.setZoomFactor(browserZoomFactor); + } + /** * Set the visibility of this view */ @@ -444,8 +464,12 @@ export class BrowserView extends Disposable implements ICDPTarget { /** * Reload the current page */ - reload(): void { - this._view.webContents.reload(); + reload(hard?: boolean): void { + if (hard) { + this._view.webContents.reloadIgnoringCache(); + } else { + this._view.webContents.reload(); + } } /** @@ -468,8 +492,7 @@ export class BrowserView extends Disposable implements ICDPTarget { async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { const quality = options?.quality ?? 80; const image = await this._view.webContents.capturePage(options?.rect, { - stayHidden: true, - stayAwake: true + stayHidden: true }); const buffer = image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); @@ -509,13 +532,6 @@ export class BrowserView extends Disposable implements ICDPTarget { } } - /** - * Set the zoom factor of this view - */ - async setZoomFactor(zoomFactor: number): Promise { - await this._view.webContents.setZoomFactor(zoomFactor); - } - /** * Focus this view */ @@ -576,6 +592,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,7 +641,9 @@ 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 }); + if (!this._view.webContents.isDestroyed()) { + this._view.webContents.close({ waitForBeforeUnload: false }); + } super.dispose(); } @@ -624,6 +658,7 @@ export class BrowserView extends Disposable implements ICDPTarget { const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow; const isNonEditingKey = + keyCode === KeyCode.Enter || keyCode === KeyCode.Escape || keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || keyCode >= KeyCode.AudioVolumeMute; @@ -663,11 +698,11 @@ export class BrowserView extends Disposable implements ICDPTarget { return true; } - 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 +710,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..c0bdb734ef708 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -60,7 +60,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)); @@ -182,18 +182,27 @@ class DebugSession extends Disposable implements ICDPConnection { constructor( public readonly sessionId: string, + private readonly _view: BrowserView, private readonly _electronDebugger: Electron.Debugger ) { super(); } - async sendMessage(method: string, params?: unknown, _sessionId?: string): Promise { + async sendCommand(method: string, params?: unknown, _sessionId?: string): Promise { // This crashes Electron. Don't pass it through. if (method === 'Emulation.setDeviceMetricsOverride') { return Promise.resolve({}); } - return this._electronDebugger.sendCommand(method, params, this.sessionId); + const result = await this._electronDebugger.sendCommand(method, params, this.sessionId); + + // Electron overrides dialog behavior in a way that this command does not auto-dismiss the dialog. + // So we manually emit the (internal) event to dismiss open dialogs when this command is sent. + if (method === 'Page.handleJavaScriptDialog') { + this._view.webContents.emit('-cancel-dialogs'); + } + + return result; } override dispose(): void { diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index d7d59c2701889..beed4f6042e9f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -6,10 +6,9 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { BrowserView } from './browserView.js'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; -import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; /** @@ -49,8 +48,8 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I constructor( readonly id: string, - @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, - @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, + private readonly windowId: number, + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService ) { super(); } @@ -127,12 +126,12 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I return this.views.values(); } - async createTarget(url: string, browserContextId?: string): Promise { + async createTarget(url: string, browserContextId?: string, windowId = this.windowId): Promise { if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId); + const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); if (target instanceof BrowserView) { await this.addView(target.id); } @@ -188,19 +187,26 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I // #region CDP endpoint - /** - * Get a WebSocket endpoint URL for connecting to this group's CDP - * session. The URL contains a short-lived, single-use token. - */ - async getDebugWebSocketEndpoint(): Promise { - return this.cdpProxyServer.getWebSocketEndpointForTarget(this); + private _debugger: CDPBrowserProxy | undefined; + get debugger(): CDPBrowserProxy { + if (!this._debugger) { + this._debugger = this._register(new CDPBrowserProxy(this)); + } + return this._debugger; + } + + async sendCDPMessage(msg: CDPRequest): Promise { + return this.debugger.sendMessage(msg); + } + + get onCDPMessage(): Event { + return this.debugger.onMessage; } // #endregion override dispose(): void { this._onDidDestroy.fire(); - this.cdpProxyServer.removeTarget(this); super.dispose(); } } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts index 20dd6331c0ea5..c34bfa16b9d92 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -9,6 +9,7 @@ import { createDecorator, IInstantiationService } from '../../instantiation/comm import { generateUuid } from '../../../base/common/uuid.js'; import { IBrowserViewGroupService, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; import { BrowserViewGroup } from './browserViewGroup.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); @@ -33,9 +34,9 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV super(); } - async createGroup(): Promise { + async createGroup(windowId: number): Promise { const id = generateUuid(); - const group = this.instantiationService.createInstance(BrowserViewGroup, id); + const group = this.instantiationService.createInstance(BrowserViewGroup, id, windowId); this.groups.set(id, group); // Auto-cleanup when the group disposes itself @@ -58,8 +59,8 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).removeView(viewId); } - async getDebugWebSocketEndpoint(groupId: string): Promise { - return this._getGroup(groupId).getDebugWebSocketEndpoint(); + async sendCDPMessage(groupId: string, message: CDPRequest): Promise { + return this._getGroup(groupId).debugger.sendMessage(message); } onDynamicDidAddView(groupId: string): Event { @@ -74,6 +75,10 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).onDidDestroy; } + onDynamicCDPMessage(groupId: string): Event { + return this._getGroup(groupId).debugger.onMessage; + } + /** * Get a group or throw if not found. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 7db0af2ac3451..83901046a8e04 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { clipboard, Menu, MenuItem } from 'electron'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -17,8 +18,13 @@ import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IProductService } from '../../product/common/productService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { logBrowserOpen } from '../common/browserViewTelemetry.js'; +import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { localize } from '../../../nls.js'; +import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; +import { ITextEditorOptions } from '../../editor/common/editor.js'; +import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -40,6 +46,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); + private _keybindings: { [commandId: string]: string } = Object.create(null); // ICDPBrowserTarget events private readonly _onTargetCreated = this._register(new Emitter()); @@ -53,38 +60,12 @@ 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 ) { 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 +139,15 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.values(); } - async createTarget(url: string, browserContextId?: string): Promise { - const targetId = generateUuid(); - const browserSession = browserContextId && BrowserSession.get(browserContextId) || BrowserSession.getOrCreateEphemeral(targetId); - - // Create the browser view (fires onTargetCreated) - const view = this.createBrowserView(targetId, browserSession); - - logBrowserOpen(this.telemetryService, 'cdpCreated'); + async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; - // 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 { @@ -278,6 +252,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).onDidClose; } + async getState(id: string): Promise { + return this._getBrowserView(id).getState(); + } + async destroyBrowserView(id: string): Promise { return this.browserViews.deleteAndDispose(id); } @@ -306,8 +284,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).goForward(); } - async reload(id: string): Promise { - return this._getBrowserView(id).reload(); + async reload(id: string, hard?: boolean): Promise { + return this._getBrowserView(id).reload(hard); } async toggleDevTools(id: string): Promise { @@ -330,10 +308,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa 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,6 +328,10 @@ 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 clearGlobalStorage(): Promise { const browserSession = BrowserSession.getOrCreateGlobal(); await browserSession.electronSession.clearData(); @@ -366,4 +344,182 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa ); await browserSession.electronSession.clearData(); } + + async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { + this._keybindings = keybindings; + } + + /** + * Create a browser view backed by the given {@link BrowserSession}. + */ + private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + if (this.browserViews.has(id)) { + throw new Error(`Browser view with id ${id} already exists`); + } + + 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.forUrl(url, targetId), [undefined, editorOptions], undefined] + }); + + return view; + } + + private showContextMenu(view: BrowserView, params: Electron.ContextMenuParams): void { + const win = view.getElectronWindow(); + if (!win) { + return; + } + const webContents = view.webContents; + if (webContents.isDestroyed()) { + return; + } + const menu = new Menu(); + + if (params.linkURL) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), + click: () => { + void this.openNew(params.linkURL, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInExternalBrowser', 'Open Link in External Browser'), + click: () => { void this.nativeHostMainService.openExternal(undefined, params.linkURL); } + })); + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyLink', 'Copy Link'), + click: () => { + clipboard.write({ + text: params.linkURL, + html: `${htmlAttributeEncodeValue(params.linkText || params.linkURL)}` + }); + } + })); + } + + if (params.hasImageContents && params.srcURL) { + if (menu.items.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), + click: () => { + void this.openNew(params.srcURL!, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImage', 'Copy Image'), + click: () => { view.webContents.copyImageAt(params.x, params.y); } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImageUrl', 'Copy Image URL'), + click: () => { clipboard.writeText(params.srcURL!); } + })); + } + + if (params.isEditable) { + menu.append(new MenuItem({ role: 'cut', enabled: params.editFlags.canCut })); + menu.append(new MenuItem({ role: 'copy', enabled: params.editFlags.canCopy })); + menu.append(new MenuItem({ role: 'paste', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'selectAll', enabled: params.editFlags.canSelectAll })); + } else if (params.selectionText) { + menu.append(new MenuItem({ role: 'copy' })); + } + + // Add navigation items as defaults + if (menu.items.length === 0) { + if (webContents.navigationHistory.canGoBack()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.back', 'Back'), + accelerator: this._keybindings[BrowserViewCommandId.GoBack], + click: () => webContents.navigationHistory.goBack() + })); + } + if (webContents.navigationHistory.canGoForward()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.forward', 'Forward'), + accelerator: this._keybindings[BrowserViewCommandId.GoForward], + click: () => webContents.navigationHistory.goForward() + })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.reload', 'Reload'), + accelerator: this._keybindings[BrowserViewCommandId.Reload], + click: () => webContents.reload() + })); + } + + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.inspect', 'Inspect'), + click: () => webContents.inspectElement(params.x, params.y) + })); + + const viewBounds = view.getWebContentsView().getBounds(); + menu.popup({ + window: win, + x: viewBounds.x + params.x, + y: viewBounds.y + params.y, + sourceType: params.menuSourceType + }); + } } diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts index b4aaffb612d17..063a5b158b543 100644 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -6,11 +6,9 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { IBrowserViewGroup, IBrowserViewGroupService, IBrowserViewGroupViewEvent, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; - -export const IBrowserViewGroupRemoteService = createDecorator('browserViewGroupRemoteService'); +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; /** * Remote-process service for managing browser view groups. @@ -22,12 +20,11 @@ export const IBrowserViewGroupRemoteService = createDecorator; + createGroup(windowId: number): Promise; } /** @@ -66,8 +63,12 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { return this.groupService.removeViewFromGroup(this.id, viewId); } - async getDebugWebSocketEndpoint(): Promise { - return this.groupService.getDebugWebSocketEndpoint(this.id); + async sendCDPMessage(msg: CDPRequest): Promise { + return this.groupService.sendCDPMessage(this.id, msg); + } + + get onCDPMessage(): Event { + return this.groupService.onDynamicCDPMessage(this.id); } override dispose(fromService = false): void { @@ -79,20 +80,18 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { } export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteService { - declare readonly _serviceBrand: undefined; - private readonly _groupService: IBrowserViewGroupService; private readonly _groups = new Map(); constructor( - @IMainProcessService mainProcessService: IMainProcessService, + mainProcessService: IMainProcessService, ) { const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); this._groupService = ProxyChannel.toService(channel); } - async createGroup(): Promise { - const id = await this._groupService.createGroup(); + async createGroup(windowId: number): Promise { + const id = await this._groupService.createGroup(windowId); return this._wrap(id); } diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts new file mode 100644 index 0000000000000..ca3e83c00a2e5 --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { ILogService } from '../../log/common/log.js'; +import { BrowserViewGroupRemoteService } from './browserViewGroupRemoteService.js'; +import { PlaywrightService } from './playwrightService.js'; + +/** + * IPC channel for the Playwright service. + * + * Each connected window gets its own {@link PlaywrightService}, + * keyed by the opaque IPC connection context. The client sends an + * `__initialize` call with its numeric window ID before any other + * method calls, which eagerly creates the instance. When a window + * disconnects the instance is automatically disposed. + */ +export class PlaywrightChannel extends Disposable implements IServerChannel { + + private readonly _instances = this._register(new DisposableMap()); + private readonly browserViewGroupRemoteService: BrowserViewGroupRemoteService; + + constructor( + ipcServer: IPCServer, + mainProcessService: IMainProcessService, + private readonly logService: ILogService, + ) { + super(); + this.browserViewGroupRemoteService = new BrowserViewGroupRemoteService(mainProcessService); + this._register(ipcServer.onDidRemoveConnection(c => { + this._instances.deleteAndDispose(c.ctx); + })); + } + + listen(ctx: string, event: string): Event { + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + const source = (instance as unknown as Record>)[event]; + if (typeof source !== 'function') { + throw new Error(`Event not found: ${event}`); + } + return source as Event; + } + + call(ctx: string, command: string, arg?: unknown): Promise { + // Handle the one-time initialization call that creates the instance + if (command === '__initialize') { + if (typeof arg !== 'number') { + throw new Error(`Invalid argument for __initialize: expected window ID as number, got ${typeof arg}`); + } + if (!this._instances.has(ctx)) { + const windowId = arg as number; + this._instances.set(ctx, new PlaywrightService(windowId, this.browserViewGroupRemoteService, this.logService)); + } + return Promise.resolve(undefined as T); + } + + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + + const target = (instance as unknown as Record)[command]; + if (typeof target !== 'function') { + throw new Error(`Method not found: ${command}`); + } + + const methodArgs = Array.isArray(arg) ? arg : []; + let res = target.apply(instance, methodArgs); + if (!(res instanceof Promise)) { + res = Promise.resolve(res); + } + return res; + } +} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 3016e0a3659e2..8abf560a5c315 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -10,12 +10,25 @@ import { ILogService } from '../../log/common/log.js'; import { IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; import { PlaywrightTab } from './playwrightTab.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; +interface PlaywrightTransport { + send(s: CDPRequest): void; + close(): void; // Note: calling close is expected to issue onclose at some point. + onmessage?: (message: CDPResponse | CDPEvent) => void; + onclose?: (reason?: string) => void; +} + +declare module 'playwright-core' { + interface BrowserType { + _connectOverCDPTransport(transport: PlaywrightTransport): Promise; + } +} + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -33,8 +46,9 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _initPromise: Promise | undefined; constructor( - @IBrowserViewGroupRemoteService private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, - @ILogService private readonly logService: ILogService, + private readonly windowId: number, + private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, + private readonly logService: ILogService, ) { super(); this._pages = this._register(new PlaywrightPageManager(logService)); @@ -77,12 +91,21 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = await this.browserViewGroupRemoteService.createGroup(); + const group = await this.browserViewGroupRemoteService.createGroup(this.windowId); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); - const endpoint = await group.getDebugWebSocketEndpoint(); - const browser = await playwright.chromium.connectOverCDP(endpoint); + const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); + const transport: PlaywrightTransport = { + close() { + sub.dispose(); + this.onclose?.(); + }, + send(message) { + void group.sendCDPMessage(message); + } + }; + const browser = await playwright.chromium._connectOverCDPTransport(transport); this.logService.debug('[PlaywrightService] Connected to browser'); @@ -125,18 +148,22 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.getSummary(pageId, true); } + async invokeFunctionRaw(pageId: string, fnDef: string, ...args: unknown[]): Promise { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + return this._pages.runAgainstPage(pageId, (page) => fn(page, args)); + } + async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); try { - await this.initialize(); - - const vm = await import('vm'); - const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); - let result; try { - result = await this._pages.runAgainstPage(pageId, (page) => fn(page, args)); + result = await this.invokeFunctionRaw(pageId, fnDef, ...args); } catch (err: unknown) { result = err instanceof Error ? err.message : String(err); } @@ -155,16 +182,6 @@ export class PlaywrightService extends Disposable implements IPlaywrightService } } - async captureScreenshot(pageId: string, selector?: string, fullPage?: boolean): Promise { - await this.initialize(); - return this._pages.runAgainstPage(pageId, async page => { - const screenshotBuffer = selector - ? await page.locator(selector).screenshot({ type: 'jpeg', quality: 80 }) - : await page.screenshot({ type: 'jpeg', quality: 80, fullPage: fullPage ?? false }); - return VSBuffer.wrap(screenshotBuffer); - }); - } - async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { await this.initialize(); const summary = await this._pages.replyToFileChooser(pageId, files); diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 231daf0fba047..0a73676455fe1 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -42,7 +42,6 @@ export class PlaywrightTab { page.on('console', event => this._handleConsoleMessage(event)) .on('pageerror', error => this._handlePageError(error)) .on('requestfailed', request => this._handleRequestFailed(request)) - .on('filechooser', chooser => this._handleFileChooser(chooser)) .on('dialog', dialog => this._handleDialog(dialog)) .on('download', download => this._handleDownload(download)); @@ -70,7 +69,7 @@ export class PlaywrightTab { async replyToDialog(accept?: boolean, promptText?: string) { if (!this._dialog) { - throw new Error('No active dialog to respond to'); + throw new Error('No active modal dialog to respond to'); } const dialog = this._dialog; this._dialog = undefined; @@ -90,7 +89,7 @@ export class PlaywrightTab { async replyToFileChooser(files: string[]) { if (!this._fileChooser) { - throw new Error('No active file chooser to respond to'); + throw new Error('No active file chooser dialog to respond to'); } const chooser = this._fileChooser; this._fileChooser = undefined; @@ -118,8 +117,12 @@ export class PlaywrightTab { /** * Run a callback against the page and wait for it to complete. + * * Because dialogs pause the page, execution races against any dialog that opens -- if a dialog * appears before the callback finishes, the method throws so the caller can surface it to the agent. + * + * Also allows for interactions to be handled differently when triggered by agents. + * E.g. file dialogs should appear when the user triggers one, but not when the agent does. */ async safeRunAgainstPage(action: (page: playwright.Page, token: CancellationToken) => Promise): Promise { if (this._dialog) { @@ -130,8 +133,20 @@ export class PlaywrightTab { let result: T | void; const dialogOpened = Event.toPromise(this._onDialogStateChanged.event); const actionCompleted = createCancelablePromise(async (token) => { - result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); - actionDidComplete = true; + + // Whenever the page has a `filechooser` handler, the default file chooser is disabled. + // We don't want this during normal user interactions, but we do for agentic interactions. + // So we add a handler just during the action, and remove it afterwards. + // This isn't perfect (e.g. the user could trigger it while an action is running), but it's a best effort. + const handleFileChooser = (chooser: playwright.FileChooser) => this._handleFileChooser(chooser); + this.page.on('filechooser', handleFileChooser); + + try { + result = await this.runAndWaitForCompletion((token) => action(this.page, token), token); + actionDidComplete = true; + } finally { + this.page.off('filechooser', handleFileChooser); + } }); return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { diff --git a/src/vs/platform/contextkey/common/contextkey.ts b/src/vs/platform/contextkey/common/contextkey.ts index 22d31d714c99a..595a8d4bbd145 100644 --- a/src/vs/platform/contextkey/common/contextkey.ts +++ b/src/vs/platform/contextkey/common/contextkey.ts @@ -937,11 +937,29 @@ export class ContextKeyInExpr implements IContextKeyExpression { if (Array.isArray(source)) { // eslint-disable-next-line local/code-no-any-casts - return source.includes(item as any); + if (source.includes(item as any)) { + return true; + } + // On Windows, file paths are case-insensitive so file URI + // comparisons must be done in a case-insensitive manner. + if (isWindows && typeof item === 'string' && item.startsWith('file:///')) { + const itemLower = item.toLowerCase(); + return source.some(s => typeof s === 'string' && s.toLowerCase() === itemLower); + } + return false; } if (typeof item === 'string' && typeof source === 'object' && source !== null) { - return hasOwnProperty.call(source, item); + if (hasOwnProperty.call(source, item)) { + return true; + } + // On Windows, file paths are case-insensitive so file URI + // property lookups must be done in a case-insensitive manner. + if (isWindows && item.startsWith('file:///')) { + const itemLower = item.toLowerCase(); + return Object.keys(source).some(key => key.toLowerCase() === itemLower); + } + return false; } return false; } diff --git a/src/vs/platform/contextkey/test/common/contextkey.test.ts b/src/vs/platform/contextkey/test/common/contextkey.test.ts index cf7ebe78a9669..8307e894e2fca 100644 --- a/src/vs/platform/contextkey/test/common/contextkey.test.ts +++ b/src/vs/platform/contextkey/test/common/contextkey.test.ts @@ -183,6 +183,19 @@ suite('ContextKeyExpr', () => { assert.strictEqual(ainb.evaluate(createContext({ 'a': 'x', 'b': { 'x': false } })), true); assert.strictEqual(ainb.evaluate(createContext({ 'a': 'x', 'b': { 'x': true } })), true); assert.strictEqual(ainb.evaluate(createContext({ 'a': 'prototype', 'b': {} })), false); + + // file URI case-insensitive comparison on Windows + if (isWindows) { + // Array source: file URIs with different casing should match on Windows + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/users/path/file.ts'] })), true); + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), true); + // Object source: file URIs with different casing should match on Windows + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': { 'file:///c%3A/users/path/file.ts': true } })), true); + // Non-file URIs should still be case-sensitive + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'git:/path/File.ts', 'b': ['git:/path/file.ts'] })), false); + // Exact match still works + assert.strictEqual(ainb.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), true); + } }); test('ContextKeyNotInExpr', () => { @@ -198,6 +211,13 @@ suite('ContextKeyExpr', () => { assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'x', 'b': { 'x': false } })), false); assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'x', 'b': { 'x': true } })), false); assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'prototype', 'b': {} })), true); + + // file URI case-insensitive comparison on Windows + if (isWindows) { + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'file:///c%3A/Users/path/file.ts', 'b': ['file:///c%3A/users/path/file.ts'] })), false); + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'file:///c%3A/users/path/file.ts', 'b': ['file:///c%3A/Users/path/file.ts'] })), false); + assert.strictEqual(aNotInB.evaluate(createContext({ 'a': 'git:/path/File.ts', 'b': ['git:/path/file.ts'] })), true); + } }); test('issue #106524: distributing AND should normalize', () => { diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index cd67c68841230..5e543ddd942a7 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -3,15 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ICopilotTokenInfo, IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; import { Event } from '../../../base/common/event.js'; -import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IPolicyData } from '../../../base/common/defaultAccount.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; export interface IDefaultAccountProvider { readonly defaultAccount: IDefaultAccount | null; readonly onDidChangeDefaultAccount: Event; readonly policyData: IPolicyData | null; readonly onDidChangePolicyData: Event; + readonly copilotTokenInfo: ICopilotTokenInfo | null; + readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; @@ -25,6 +27,8 @@ export interface IDefaultAccountService { readonly onDidChangeDefaultAccount: Event; readonly onDidChangePolicyData: Event; readonly policyData: IPolicyData | null; + readonly copilotTokenInfo: ICopilotTokenInfo | null; + readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/platform/dialogs/common/dialogs.ts b/src/vs/platform/dialogs/common/dialogs.ts index f80914ca0b58f..fc73e57f82433 100644 --- a/src/vs/platform/dialogs/common/dialogs.ts +++ b/src/vs/platform/dialogs/common/dialogs.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; @@ -37,6 +38,17 @@ export interface IBaseDialogOptions { * Allows to enforce use of custom dialog even in native environments. */ readonly custom?: boolean | ICustomDialogOptions; + + /** + * An optional cancellation token that can be used to dismiss the dialog + * programmatically for custom dialog implementations. + * + * When cancelled, the custom dialog resolves as if the cancel button was + * pressed. Native dialog handlers cannot currently be dismissed + * programmatically and ignore this option unless a custom dialog is + * explicitly enforced via the {@link custom} option. + */ + readonly token?: CancellationToken; } export interface IConfirmDialogArgs { diff --git a/src/vs/platform/download/common/download.ts b/src/vs/platform/download/common/download.ts index d608e078ecbdc..8d26b20e5a35c 100644 --- a/src/vs/platform/download/common/download.ts +++ b/src/vs/platform/download/common/download.ts @@ -13,6 +13,6 @@ export interface IDownloadService { readonly _serviceBrand: undefined; - download(uri: URI, to: URI, cancellationToken?: CancellationToken): Promise; + download(uri: URI, to: URI, callSite: string, cancellationToken?: CancellationToken): Promise; } diff --git a/src/vs/platform/download/common/downloadIpc.ts b/src/vs/platform/download/common/downloadIpc.ts index c3ba6d6c249de..efd3a3e2113c0 100644 --- a/src/vs/platform/download/common/downloadIpc.ts +++ b/src/vs/platform/download/common/downloadIpc.ts @@ -19,7 +19,7 @@ export class DownloadServiceChannel implements IServerChannel { call(context: any, command: string, args?: any): Promise { switch (command) { - case 'download': return this.service.download(URI.revive(args[0]), URI.revive(args[1])); + case 'download': return this.service.download(URI.revive(args[0]), URI.revive(args[1]), args[2] ?? 'downloadIpc'); } throw new Error('Invalid call'); } @@ -31,7 +31,7 @@ export class DownloadServiceChannelClient implements IDownloadService { constructor(private channel: IChannel, private getUriTransformer: () => IURITransformer | null) { } - async download(from: URI, to: URI): Promise { + async download(from: URI, to: URI, _callSite?: string): Promise { const uriTransformer = this.getUriTransformer(); if (uriTransformer) { from = uriTransformer.transformOutgoingURI(from); diff --git a/src/vs/platform/download/common/downloadService.ts b/src/vs/platform/download/common/downloadService.ts index 79cedcb1668ed..4782f50658835 100644 --- a/src/vs/platform/download/common/downloadService.ts +++ b/src/vs/platform/download/common/downloadService.ts @@ -19,13 +19,13 @@ export class DownloadService implements IDownloadService { @IFileService private readonly fileService: IFileService ) { } - async download(resource: URI, target: URI, cancellationToken: CancellationToken = CancellationToken.None): Promise { + async download(resource: URI, target: URI, callSite: string, cancellationToken: CancellationToken = CancellationToken.None): Promise { if (resource.scheme === Schemas.file || resource.scheme === Schemas.vscodeRemote) { // Intentionally only support this for file|remote<->file|remote scenarios await this.fileService.copy(resource, target); return; } - const options = { type: 'GET', url: resource.toString(true) }; + const options = { type: 'GET' as const, url: resource.toString(true), callSite }; const context = await this.requestService.request(options, cancellationToken); if (context.res.statusCode === 200) { await this.fileService.writeFile(target, context.stream); diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index 281ee03246a20..d0751cb4bc983 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -329,6 +329,21 @@ 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. diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 45cb23ba6f3f7..7293857263511 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -121,6 +121,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/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 6d00ad0ae0908..9068c9ad77acf 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -166,6 +166,7 @@ export const OPTIONS: OptionDescriptions> = { 'export-policy-data': { 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/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/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index db714398cdaad..37e1901f0a7e1 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: 3 }, 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', @@ -426,6 +429,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', }, 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/launch/electron-main/launchMainService.ts b/src/vs/platform/launch/electron-main/launchMainService.ts index db2fd75d13495..274600742e45a 100644 --- a/src/vs/platform/launch/electron-main/launchMainService.ts +++ b/src/vs/platform/launch/electron-main/launchMainService.ts @@ -18,6 +18,7 @@ import { ICodeWindow } from '../../window/electron-main/window.js'; import { IWindowSettings } from '../../window/common/window.js'; import { IOpenConfiguration, IWindowsMainService, OpenContext } from '../../windows/electron-main/windows.js'; import { IProtocolUrl } from '../../url/electron-main/url.js'; +import { IProductService } from '../../product/common/productService.js'; export const ID = 'launchMainService'; export const ILaunchMainService = createDecorator(ID); @@ -45,6 +46,7 @@ export class LaunchMainService implements ILaunchMainService { @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IURLService private readonly urlService: IURLService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, ) { } async start(args: NativeParsedArgs, userEnv: IProcessEnvironment): Promise { @@ -111,6 +113,7 @@ export class LaunchMainService implements ILaunchMainService { private async startOpenWindow(args: NativeParsedArgs, userEnv: IProcessEnvironment): Promise { const context = isLaunchedFromCli(userEnv) ? OpenContext.CLI : OpenContext.DESKTOP; + let usedWindows: ICodeWindow[] = []; const waitMarkerFileURI = args.wait && args.waitMarkerFilePath ? URI.file(args.waitMarkerFilePath) : undefined; @@ -142,6 +145,11 @@ export class LaunchMainService implements ILaunchMainService { await this.windowsMainService.openExtensionDevelopmentHostWindow(args.extensionDevelopmentPath, baseConfig); } + // Sessions window + else if (args['sessions'] && this.productService.quality !== 'stable') { + usedWindows = await this.windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); + } + // Start without file/folder arguments else if (!args._.length && !args['folder-uri'] && !args['file-uri']) { let openNewWindow = false; diff --git a/src/vs/platform/mcp/common/mcpGalleryManifestService.ts b/src/vs/platform/mcp/common/mcpGalleryManifestService.ts index 377d30712606d..a86f67c36d46b 100644 --- a/src/vs/platform/mcp/common/mcpGalleryManifestService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryManifestService.ts @@ -134,6 +134,7 @@ export class McpGalleryManifestService extends Disposable implements IMcpGallery const context = await this.requestService.request({ type: 'GET', url: `${url}/${version}/servers?limit=1`, + callSite: 'mcpGalleryManifestService.checkVersion' }, CancellationToken.None); if (isSuccess(context)) { return true; diff --git a/src/vs/platform/mcp/common/mcpGalleryService.ts b/src/vs/platform/mcp/common/mcpGalleryService.ts index 5de645fe43699..6dbb16e296094 100644 --- a/src/vs/platform/mcp/common/mcpGalleryService.ts +++ b/src/vs/platform/mcp/common/mcpGalleryService.ts @@ -816,6 +816,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url: readmeUrl, + callSite: 'mcpGalleryService.getReadme' }, token); const result = await asText(context); @@ -951,6 +952,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url, + callSite: 'mcpGalleryService.queryMcpServers' }, token); const data = await asJson(context); @@ -972,6 +974,7 @@ export class McpGalleryService extends Disposable implements IMcpGalleryService const context = await this.requestService.request({ type: 'GET', url: mcpServerUrl, + callSite: 'mcpGalleryService.getMcpServer' }, CancellationToken.None); if (context.res.statusCode && context.res.statusCode >= 400 && context.res.statusCode < 500) { diff --git a/src/vs/platform/mcp/common/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..78d0e93a3f0d2 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -6,6 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ILoggerService } from '../../log/common/log.js'; import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -19,10 +20,14 @@ export class McpGatewayChannel extends Disposable implements IServerCh 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); + })); } listen(_ctx: TContext, _event: string): Event { @@ -30,6 +35,9 @@ export class McpGatewayChannel extends Disposable implements IServerCh } 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); @@ -42,9 +50,11 @@ export class McpGatewayChannel extends Disposable implements IServerCh readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), }); + logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} for client ${ctx}`); return result as T; } case 'disposeGateway': { + logger?.info(`[McpGateway][Channel] Disposing gateway: ${args as string} for client ${ctx}`); await this.mcpGatewayService.disposeGateway(args as string); return undefined as T; } diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index 8225b3fffe8c3..6131c7677dab6 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -9,7 +9,7 @@ import { JsonRpcMessage, JsonRpcProtocol } from '../../../base/common/jsonRpcPro import { Disposable } 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 { ILogger, ILoggerService } from '../../log/common/log.js'; import { IMcpGatewayInfo, IMcpGatewayService, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; import { isInitializeMessage, McpGatewaySession } from './mcpGatewaySession.js'; @@ -28,11 +28,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService /** Maps gatewayId to clientId for tracking ownership */ private readonly _gatewayToClient = 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 { @@ -51,15 +54,16 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService throw new Error('[McpGatewayService] Tool invoker is required to create gateway'); } - const gateway = new McpGatewayRoute(gatewayId, this._logService, toolInvoker); + const gateway = new McpGatewayRoute(gatewayId, this._logger, toolInvoker); this._gateways.set(gatewayId, gateway); + this._logger.info(`[McpGatewayService] Active gateways: ${this._gateways.size}`); // 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}`); + this._logger.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}`); + this._logger.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); } const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`); @@ -73,14 +77,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService async disposeGateway(gatewayId: string): Promise { const gateway = this._gateways.get(gatewayId); if (!gateway) { - this._logService.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${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._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining: ${this._gateways.size})`); // If no more gateways, shut down the server if (this._gateways.size === 0) { @@ -98,7 +102,7 @@ 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}: [${gatewaysToDispose.join(', ')}]`); for (const gatewayId of gatewaysToDispose) { this._gateways.get(gatewayId)?.dispose(); @@ -156,19 +160,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 +187,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 gateways)'); 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,6 +205,8 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const url = new URL(req.url!, `http://${req.headers.host}`); const pathParts = url.pathname.split('/').filter(Boolean); + this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active gateways: ${this._gateways.size})`); + // Expected path: /gateway/{gatewayId} if (pathParts.length >= 2 && pathParts[0] === 'gateway') { const gatewayId = pathParts[1]; @@ -213,11 +219,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } // Not found + this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: gateway 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 (gateways: ${this._gateways.size})`); this._stopServer(); for (const gateway of this._gateways.values()) { gateway.dispose(); @@ -237,13 +245,15 @@ class McpGatewayRoute extends Disposable { constructor( public readonly gatewayId: string, - private readonly _logService: ILogService, + private readonly _logger: ILogger, private readonly _toolInvoker: IMcpGatewayToolInvoker, ) { super(); } handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] ${req.method} request (sessions: ${this._sessions.size})`); + if (req.method === 'POST') { void this._handlePost(req, res); return; @@ -263,6 +273,7 @@ class McpGatewayRoute extends Disposable { } public override dispose(): void { + this._logger.info(`[McpGateway][route ${this.gatewayId}] Disposing route (sessions: ${this._sessions.size})`); for (const session of this._sessions.values()) { session.dispose(); } @@ -283,6 +294,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] Deleting session ${sessionId}`); session.dispose(); this._sessions.delete(sessionId); res.writeHead(204); @@ -302,6 +314,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] SSE connection requested for session ${sessionId}`); session.attachSseClient(req, res); } @@ -312,10 +325,13 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.debug(`[McpGateway][route ${this.gatewayId}] Handling POST`); + let message: JsonRpcMessage | JsonRpcMessage[]; try { message = JSON.parse(body) as JsonRpcMessage | JsonRpcMessage[]; } catch (error) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] 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 +352,18 @@ class McpGatewayRoute extends Disposable { }; if (responses.length === 0) { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] 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.gatewayId}] 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 +372,7 @@ class McpGatewayRoute extends Disposable { if (headerSessionId) { const existing = this._sessions.get(headerSessionId); if (!existing) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] Session not found: ${headerSessionId}`); this._respondHttpError(res, 404, 'Session not found'); return undefined; } @@ -366,7 +386,8 @@ class McpGatewayRoute extends Disposable { } const sessionId = generateUuid(); - const session = new McpGatewaySession(sessionId, this._logService, () => { + this._logger.info(`[McpGateway][route ${this.gatewayId}] Creating new session ${sessionId}`); + const session = new McpGatewaySession(sessionId, this._logger, () => { this._sessions.delete(sessionId); }, this._toolInvoker); this._sessions.set(sessionId, session); @@ -374,6 +395,7 @@ class McpGatewayRoute extends Disposable { } private _respondHttpError(res: http.ServerResponse, statusCode: number, error: string): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] 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..20f6d23dc7162 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -6,15 +6,22 @@ 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 { ILogger } from '../../log/common/log.js'; import { IMcpGatewayToolInvoker } 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; @@ -72,14 +79,12 @@ function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: n 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, ) { @@ -98,6 +103,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Tools changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); @@ -106,6 +112,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Resources changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); })); } @@ -119,25 +126,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 +152,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 +167,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 +194,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 +219,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,21 +279,27 @@ 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); + this._logService.debug(`[McpGateway][session ${this.id}] Tool '${params.name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return { ...result, content: encodeResourceUrisInContent(result.content, serverIndex), }; } 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 })); + .then(tools => { + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); + return { tools }; + }); } private async _handleListResources(): Promise { @@ -285,6 +313,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allResources.length} resource(s) from ${serverResults.length} server(s)`); return { resources: allResources }; } @@ -295,8 +324,10 @@ export class McpGatewaySession extends Disposable { } const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${originalUri}' from server ${serverIndex}`); try { const result = await this._toolInvoker.readResource(serverIndex, originalUri); + this._logService.debug(`[McpGateway][session ${this.id}] Resource read returned ${result.contents.length} content(s)`); return { contents: result.contents.map(content => ({ ...content, @@ -304,7 +335,7 @@ export class McpGatewaySession extends Disposable { })), }; } catch (error) { - this._logService.error('[McpGatewayService] Resource read failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${originalUri}'`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } @@ -320,6 +351,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allTemplates.length} resource template(s) from ${serverResults.length} server(s)`); return { resourceTemplates: allTemplates }; } } 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..d30d166d09fbf 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -112,6 +112,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); 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/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 8e5283ef9ad92..162d90de81b21 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -922,13 +922,12 @@ export class QuickInputController extends Disposable { private updateStyles() { if (this.ui) { const { - quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, widgetShadow, + quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, } = this.styles.widget; this.ui.titleBar.style.backgroundColor = quickInputTitleBackground ?? ''; this.ui.container.style.backgroundColor = quickInputBackground ?? ''; this.ui.container.style.color = quickInputForeground ?? ''; this.ui.container.style.border = widgetBorder ? `1px solid ${widgetBorder}` : ''; - this.ui.container.style.boxShadow = widgetShadow ? `0 0 8px 2px ${widgetShadow}` : ''; this.ui.list.style(this.styles.list); this.ui.tree.tree.style(this.styles.list); diff --git a/src/vs/platform/quickinput/browser/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..04d91e66aa488 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -1172,6 +1172,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..db57dedbffa01 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -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, { 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/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/common/sandboxHelperIpc.ts b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts new file mode 100644 index 0000000000000..bbcdf499df193 --- /dev/null +++ b/src/vs/platform/sandbox/common/sandboxHelperIpc.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const SandboxHelperChannelName = 'SandboxHelper'; + +export interface ISandboxNetworkHostPattern { + readonly host: string; + readonly port: number | undefined; +} + +export interface ISandboxNetworkConfig { + readonly allowedDomains?: string[]; + readonly deniedDomains?: string[]; + readonly allowUnixSockets?: string[]; + readonly allowAllUnixSockets?: boolean; + readonly allowLocalBinding?: boolean; + readonly httpProxyPort?: number; + readonly socksProxyPort?: number; +} + +export interface ISandboxFilesystemConfig { + readonly denyRead?: string[]; + readonly allowWrite?: string[]; + readonly denyWrite?: string[]; + readonly allowGitConfig?: boolean; +} + +export interface ISandboxRuntimeConfig { + readonly network?: ISandboxNetworkConfig; + readonly filesystem?: ISandboxFilesystemConfig; + readonly ignoreViolations?: Record; + readonly enableWeakerNestedSandbox?: boolean; + readonly ripgrep?: { + readonly command: string; + readonly args?: string[]; + }; + readonly mandatoryDenySearchDepth?: number; + readonly allowPty?: boolean; +} + +export interface ISandboxPermissionRequest extends ISandboxNetworkHostPattern { + readonly requestId: string; +} diff --git a/src/vs/platform/sandbox/common/sandboxHelperService.ts b/src/vs/platform/sandbox/common/sandboxHelperService.ts new file mode 100644 index 0000000000000..4556c74bc546e --- /dev/null +++ b/src/vs/platform/sandbox/common/sandboxHelperService.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. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { type ISandboxPermissionRequest, type ISandboxRuntimeConfig } from './sandboxHelperIpc.js'; + +export const ISandboxHelperService = createDecorator('ISandboxHelperService'); + +export interface ISandboxHelperService { + readonly _serviceBrand: undefined; + readonly onDidRequestSandboxPermission: Event; + + resetSandbox(): Promise; + resolveSandboxPermissionRequest(requestId: string, allowed: boolean): Promise; + wrapWithSandbox(runtimeConfig: ISandboxRuntimeConfig, command: string): Promise; +} diff --git a/extensions/github-authentication/extension.webpack.config.js b/src/vs/platform/sandbox/electron-browser/sandboxHelperService.ts similarity index 52% rename from extensions/github-authentication/extension.webpack.config.js rename to src/vs/platform/sandbox/electron-browser/sandboxHelperService.ts index 166c1d8b1e340..19778b5bf4bc6 100644 --- a/extensions/github-authentication/extension.webpack.config.js +++ b/src/vs/platform/sandbox/electron-browser/sandboxHelperService.ts @@ -2,12 +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: { - extension: './src/extension.ts', - }, -}); +import { registerMainProcessRemoteService } from '../../ipc/electron-browser/services.js'; +import { SandboxHelperChannelName } from '../common/sandboxHelperIpc.js'; +import { ISandboxHelperService } from '../common/sandboxHelperService.js'; + +registerMainProcessRemoteService(ISandboxHelperService, SandboxHelperChannelName); diff --git a/src/vs/platform/sandbox/node/sandboxHelperChannel.ts b/src/vs/platform/sandbox/node/sandboxHelperChannel.ts new file mode 100644 index 0000000000000..9b861b011e954 --- /dev/null +++ b/src/vs/platform/sandbox/node/sandboxHelperChannel.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 { Event } from '../../../base/common/event.js'; +import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { RemoteAgentConnectionContext } from '../../remote/common/remoteAgentEnvironment.js'; +import { type ISandboxRuntimeConfig } from '../common/sandboxHelperIpc.js'; +import { ISandboxHelperService } from '../common/sandboxHelperService.js'; + +export class SandboxHelperChannel implements IServerChannel { + + constructor( + @ISandboxHelperService private readonly sandboxHelperService: ISandboxHelperService + ) { } + + listen(context: RemoteAgentConnectionContext, event: string): Event { + switch (event) { + case 'onDidRequestSandboxPermission': { + return this.sandboxHelperService.onDidRequestSandboxPermission as Event; + } + } + + throw new Error('Invalid listen'); + } + + async call(context: RemoteAgentConnectionContext, command: string, args?: unknown): Promise { + const argsArray = Array.isArray(args) ? args : []; + switch (command) { + case 'resetSandbox': { + return this.sandboxHelperService.resetSandbox() as T; + } + case 'resolveSandboxPermissionRequest': { + return this.sandboxHelperService.resolveSandboxPermissionRequest(argsArray[0] as string, argsArray[1] as boolean) as T; + } + case 'wrapWithSandbox': { + return this.sandboxHelperService.wrapWithSandbox(argsArray[0] as ISandboxRuntimeConfig, argsArray[1] as string) as T; + } + } + + throw new Error('Invalid call'); + } +} diff --git a/src/vs/platform/sandbox/node/sandboxHelperService.ts b/src/vs/platform/sandbox/node/sandboxHelperService.ts new file mode 100644 index 0000000000000..90ba6e217d7ab --- /dev/null +++ b/src/vs/platform/sandbox/node/sandboxHelperService.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SandboxManager, type NetworkHostPattern } from '@anthropic-ai/sandbox-runtime'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { dirname, posix, win32 } from '../../../base/common/path.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IEnvironmentService, INativeEnvironmentService } from '../../environment/common/environment.js'; +import { type ISandboxPermissionRequest, type ISandboxRuntimeConfig } from '../common/sandboxHelperIpc.js'; +import { ISandboxHelperService } from '../common/sandboxHelperService.js'; + +export class SandboxHelperService extends Disposable implements ISandboxHelperService { + declare readonly _serviceBrand: undefined; + private readonly _onDidRequestSandboxPermission = this._register(new Emitter()); + readonly onDidRequestSandboxPermission = this._onDidRequestSandboxPermission.event; + private readonly _pendingPermissionRequests = new Map void>(); + private readonly _rgPath: string | undefined; + private readonly _tempDir: string | undefined; + + constructor( + @IEnvironmentService environmentService: IEnvironmentService, + ) { + super(); + const nativeEnvironmentService = environmentService as IEnvironmentService & Partial; + this._rgPath = nativeEnvironmentService.appRoot + ? this._pathJoin(nativeEnvironmentService.appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg') + : undefined; + this._tempDir = nativeEnvironmentService.tmpDir?.path; + } + + async resolveSandboxPermissionRequest(requestId: string, allowed: boolean): Promise { + const resolver = this._pendingPermissionRequests.get(requestId); + if (!resolver) { + throw new Error(`No pending sandbox permission request with id ${requestId}`); + } + this._pendingPermissionRequests.delete(requestId); + resolver(allowed); + } + + async resetSandbox(): Promise { + await SandboxManager.reset(); + } + + async wrapWithSandbox(runtimeConfig: ISandboxRuntimeConfig, command: string): Promise { + const normalizedRuntimeConfig = { + network: { + // adding at least one domain or else the sandbox doesnt do any proxy setup. + allowedDomains: runtimeConfig.network?.allowedDomains?.length ? [...runtimeConfig.network.allowedDomains] : ['microsoft.com'], + deniedDomains: [...(runtimeConfig.network?.deniedDomains ?? [])], + allowUnixSockets: runtimeConfig.network?.allowUnixSockets ? [...runtimeConfig.network.allowUnixSockets] : undefined, + allowAllUnixSockets: runtimeConfig.network?.allowAllUnixSockets, + allowLocalBinding: runtimeConfig.network?.allowLocalBinding, + httpProxyPort: runtimeConfig.network?.httpProxyPort, + socksProxyPort: runtimeConfig.network?.socksProxyPort, + }, + filesystem: { + denyRead: [...(runtimeConfig.filesystem?.denyRead ?? [])], + allowWrite: [ + ...(runtimeConfig.filesystem?.allowWrite ?? []), + ...(this._tempDir ? [this._tempDir] : []), + ], + denyWrite: [...(runtimeConfig.filesystem?.denyWrite ?? [])], + allowGitConfig: runtimeConfig.filesystem?.allowGitConfig, + }, + ignoreViolations: runtimeConfig.ignoreViolations, + enableWeakerNestedSandbox: runtimeConfig.enableWeakerNestedSandbox, + ripgrep: runtimeConfig.ripgrep ? { + command: runtimeConfig.ripgrep.command, + args: runtimeConfig.ripgrep.args ? [...runtimeConfig.ripgrep.args] : undefined, + } : undefined, + mandatoryDenySearchDepth: runtimeConfig.mandatoryDenySearchDepth, + allowPty: runtimeConfig.allowPty, + }; + await SandboxManager.initialize(normalizedRuntimeConfig, request => this._requestSandboxPermission(request)); + return SandboxManager.wrapWithSandbox(`${this._getSandboxEnvironmentPrefix()} ${command}`); + } + + private _getSandboxEnvironmentPrefix(): string { + const env: string[] = ['NODE_USE_ENV_PROXY=1']; + + if (this._tempDir) { + env.push(this._toEnvironmentAssignment('TMPDIR', this._tempDir)); + } + + const pathWithRipgrep = this._getPathWithRipgrepDir(); + if (pathWithRipgrep) { + env.push(this._toEnvironmentAssignment('PATH', pathWithRipgrep)); + } + + return env.join(' '); + } + + private _getPathWithRipgrepDir(): string | undefined { + if (!this._rgPath) { + return undefined; + } + const rgDir = dirname(this._rgPath); + const currentPath = process.env['PATH']; + const pathModule = process.platform === 'win32' ? win32 : posix; + const delimiter = pathModule.delimiter; + return currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; + } + + private _toEnvironmentAssignment(name: string, value: string): string { + return `${name}="${value}"`; + } + + private _pathJoin(...segments: string[]): string { + const path = process.platform === 'win32' ? win32 : posix; + return path.join(...segments); + } + + private _requestSandboxPermission(request: NetworkHostPattern): Promise { + const requestId = generateUuid(); + + return new Promise(resolve => { + this._pendingPermissionRequests.set(requestId, resolve); + this._onDidRequestSandboxPermission.fire({ + requestId, + host: request.host, + port: request.port, + }); + }); + } +} diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index 7c5c89eae0886..d1acea8ff5ddc 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -309,13 +309,14 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { } } - const nodeModulesRegex = /^[\\\/]?(node_modules|node_modules\.asar)[\\\/]/; + // Match node_modules or node_modules.asar at any position in the path, capturing the node_modules/... suffix + const nodeModulesRegex = /(?:^|[\\\/])((node_modules|node_modules\.asar)[\\\/].*)$/; // Match VS Code extension paths: // 1. User extensions: .vscode/extensions/, .vscode-insiders/extensions/, .vscode-server/extensions/, .vscode-server-insiders/extensions/, etc. // 2. Built-in extensions: resources/app/extensions/ // Capture everything from the vscode folder or resources/app/extensions onwards const vscodeExtensionsPathRegex = /^(.*?)((?:\.vscode(?:-[a-z]+)*|resources[\\\/]app)[\\\/]extensions[\\\/].*)$/i; - const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w-\._]+(\\\\|\\|\/))+[\w-\._]*/g; + const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w\-\._@]+(\\\\|\\|\/))+[\w\-\._@]*/g; let lastIndex = 0; updatedStack = ''; @@ -329,14 +330,20 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { const overlappingRange = cleanUpIndexes.some(([start, end]) => result.index < end && start < fileRegex.lastIndex); // anoynimize user file paths that do not need to be retained or cleaned up. - if (!nodeModulesRegex.test(result[0]) && !overlappingRange) { + if (!overlappingRange) { // Check if this is a VS Code extension path - if so, preserve the .vscode*/extensions/... portion const vscodeExtMatch = vscodeExtensionsPathRegex.exec(result[0]); if (vscodeExtMatch) { // Keep ".vscode[-variant]/extensions/extension-name/..." but redact the parent folder updatedStack += stack.substring(lastIndex, result.index) + '/' + vscodeExtMatch[2]; } else { - updatedStack += stack.substring(lastIndex, result.index) + ''; + // Check if node_modules appears in the path — preserve node_modules/... suffix + const nodeModulesMatch = nodeModulesRegex.exec(result[0]); + if (nodeModulesMatch) { + updatedStack += stack.substring(lastIndex, result.index) + '/' + nodeModulesMatch[1]; + } else { + updatedStack += stack.substring(lastIndex, result.index) + ''; + } } lastIndex = fileRegex.lastIndex; } diff --git a/src/vs/platform/telemetry/node/1dsAppender.ts b/src/vs/platform/telemetry/node/1dsAppender.ts index 0d3f9369eb56c..0fdbbd1a73257 100644 --- a/src/vs/platform/telemetry/node/1dsAppender.ts +++ b/src/vs/platform/telemetry/node/1dsAppender.ts @@ -7,7 +7,7 @@ import type { IPayloadData, IXHROverride } from '@microsoft/1ds-post-js'; import { streamToBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { IRequestOptions } from '../../../base/parts/request/common/request.js'; -import { IRequestService } from '../../request/common/request.js'; +import { IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; import { AbstractOneDataSystemAppender, IAppInsightsCore } from '../common/1dsAppender.js'; type OnCompleteFunc = (status: number, headers: { [headerName: string]: string }, response?: string) => void; @@ -81,7 +81,8 @@ async function sendPostAsync(requestService: IRequestService | undefined, payloa 'Content-Length': Buffer.byteLength(payload.data).toString() }, url: payload.urlString, - data: telemetryRequestData + data: telemetryRequestData, + callSite: NO_FETCH_TELEMETRY }; try { diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index d6a1b2370d511..dff2debea1131 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -57,7 +57,13 @@ class ErrorTestingSettings { public randomUserFile: string = 'a/path/that/doe_snt/con-tain/code/names.js'; public anonymizedRandomUserFile: string = ''; public nodeModulePathToRetain: string = 'node_modules/path/that/shouldbe/retained/names.js:14:15854'; + public anonymizedNodeModulePath: string = '/node_modules/path/that/shouldbe/retained/names.js:14:15854'; public nodeModuleAsarPathToRetain: string = 'node_modules.asar/path/that/shouldbe/retained/names.js:14:12354'; + public anonymizedNodeModuleAsarPath: string = '/node_modules.asar/path/that/shouldbe/retained/names.js:14:12354'; + public fullNodeModulePath: string = '/Users/username/projects/vscode/node_modules/@xterm/xterm/lib/xterm.js:1:243732'; + public anonymizedFullNodeModulePath: string = '/node_modules/@xterm/xterm/lib/xterm.js:1:243732'; + public fullNodeModuleAsarPath: string = '/Users/username/projects/vscode/node_modules.asar/@xterm/xterm/lib/xterm.js:1:376066'; + public anonymizedFullNodeModuleAsarPath: string = '/node_modules.asar/@xterm/xterm/lib/xterm.js:1:376066'; public extensionPathToRetain: string = '.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; public fullExtensionPath: string = '/Users/username/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; public anonymizedExtensionPath: string = '/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; @@ -90,6 +96,8 @@ class ErrorTestingSettings { ` at t._handleMessage (${this.nodeModuleAsarPathToRetain})`, ` at t._onmessage (/${this.nodeModulePathToRetain})`, ` at t.onmessage (${this.nodeModulePathToRetain})`, + ` at get dimensions (${this.fullNodeModulePath})`, + ` at _._refreshCanvasDimensions (${this.fullNodeModuleAsarPath})`, ` at uv.provideCodeActions (${this.fullExtensionPath})`, ` at remote.handleConnection (${this.fullServerInsidersExtensionPath})`, ` at git.getRepositoryState (${this.fullBuiltinExtensionPath})`, @@ -468,10 +476,8 @@ suite('TelemetryService', () => { assert.strictEqual(errorStub.callCount, 1); // Test that important information remains but personal info does not - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModulePathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModulePathToRetain), -1); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedNodeModuleAsarPath), -1, 'bare node_modules.asar path'); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedNodeModulePath), -1, 'bare node_modules path'); assert.notStrictEqual(testAppender.events[0].data.msg.indexOf(settings.importantInfo), -1); assert.strictEqual(testAppender.events[0].data.msg.indexOf(settings.personalInfo), -1); assert.strictEqual(testAppender.events[0].data.msg.indexOf(settings.filePrefix), -1); @@ -504,10 +510,12 @@ suite('TelemetryService', () => { Errors.onUnexpectedError(dangerousPathWithImportantInfoError); this.clock.tick(ErrorTelemetry.ERROR_FLUSH_TIMEOUT); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(' + settings.nodeModulePathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModuleAsarPathToRetain), -1); - assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf('(/' + settings.nodeModulePathToRetain), -1); + // All node_modules paths (bare and full) should preserve the node_modules/... suffix after redaction + const cs = testAppender.events[0].data.callstack; + assert.notStrictEqual(cs.indexOf(settings.anonymizedNodeModuleAsarPath), -1, 'bare node_modules.asar path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedNodeModulePath), -1, 'bare node_modules path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedFullNodeModulePath), -1, 'full node_modules path'); + assert.notStrictEqual(cs.indexOf(settings.anonymizedFullNodeModuleAsarPath), -1, 'full node_modules.asar path'); errorTelemetry.dispose(); service.dispose(); diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index dc7c596c346ca..44e53e09a6449 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -122,6 +122,7 @@ export const enum TerminalSettingId { FontLigaturesFallbackLigatures = 'terminal.integrated.fontLigatures.fallbackLigatures', EnableKittyKeyboardProtocol = 'terminal.integrated.enableKittyKeyboardProtocol', EnableWin32InputMode = 'terminal.integrated.enableWin32InputMode', + ExperimentalAiProfileGrouping = 'terminal.integrated.experimental.aiProfileGrouping', AllowInUntrustedWorkspace = 'terminal.integrated.allowInUntrustedWorkspace', // Developer/debug settings 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/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/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 93dd62df97ec6..53e3a783872ff 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -7,6 +7,7 @@ import { isWeb, isWindows } from '../../../base/common/platform.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { localize } from '../../../nls.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -89,6 +90,21 @@ configurationRegistry.registerConfiguration({ localize('actionable', "The status bar entry is shown when an action is required (e.g., download, install, or restart)."), localize('detailed', "The status bar entry is shown for all update states including progress.") ] + }, + 'update.titleBar': { + type: 'string', + enum: ['none', 'actionable', 'detailed', 'always'], + default: product.quality !== 'stable' ? 'actionable' : 'none', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + experiment: { mode: 'startup' }, + description: localize('titleBar', "Controls the experimental update title bar entry."), + enumDescriptions: [ + localize('titleBarNone', "The title bar entry is never shown."), + localize('titleBarActionable', "The title bar entry is shown when an action is required (e.g., download, install, or restart)."), + localize('titleBarDetailed', "The title bar entry is shown for progress and actionable update states, but not for idle or disabled states."), + localize('titleBarAlways', "The title bar entry is shown for all update states.") + ] } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 7f30494da4a37..bc90a03ad8c78 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -59,17 +59,17 @@ export const enum DisablementReason { NotBuilt, DisabledByEnvironment, ManuallyDisabled, + Policy, MissingConfiguration, InvalidConfiguration, RunningAsAdmin, - EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; -export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; +export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string; notAvailable?: boolean }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; -export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; +export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate; canInstall?: boolean }; export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number }; @@ -81,9 +81,9 @@ export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | Avail export const State = { Uninitialized: upcast({ type: StateType.Uninitialized }), Disabled: (reason: DisablementReason): Disabled => ({ type: StateType.Disabled, reason }), - Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), + Idle: (updateType: UpdateType, error?: string, notAvailable?: boolean): Idle => ({ type: StateType.Idle, updateType, error, notAvailable }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), - AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), + AvailableForDownload: (update: IUpdate, canInstall?: boolean): AvailableForDownload => ({ type: StateType.AvailableForDownload, update, canInstall }), Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }), @@ -111,7 +111,10 @@ export interface IUpdateService { applyUpdate(): Promise; quitAndInstall(): Promise; + /** + * @deprecated This method should not be used any more. It will be removed in a future release. + */ isLatestVersion(): Promise; _applySpecificUpdate(packagePath: string): Promise; - disableProgressiveReleases(): Promise; + setInternalOrg(internalOrg: string | undefined): Promise; } diff --git a/src/vs/platform/update/common/updateIpc.ts b/src/vs/platform/update/common/updateIpc.ts index 9eaf8210757e2..6b165c49d2146 100644 --- a/src/vs/platform/update/common/updateIpc.ts +++ b/src/vs/platform/update/common/updateIpc.ts @@ -29,7 +29,7 @@ export class UpdateChannel implements IServerChannel { case '_getInitialState': return Promise.resolve(this.service.state); case 'isLatestVersion': return this.service.isLatestVersion(); case '_applySpecificUpdate': return this.service._applySpecificUpdate(arg); - case 'disableProgressiveReleases': return this.service.disableProgressiveReleases(); + case 'setInternalOrg': return this.service.setInternalOrg(arg); } throw new Error(`Call not found: ${command}`); @@ -80,8 +80,8 @@ export class UpdateChannelClient implements IUpdateService { return this.channel.call('_applySpecificUpdate', packagePath); } - disableProgressiveReleases(): Promise { - return this.channel.call('disableProgressiveReleases'); + setInternalOrg(internalOrg: string | undefined): Promise { + return this.channel.call('setInternalOrg', internalOrg); } dispose(): void { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 698d277ca288b..c943bca4f5efb 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -20,6 +20,7 @@ import { AvailableForDownload, DisablementReason, IUpdateService, State, StateTy 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 +30,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 +80,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; @@ -139,11 +142,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; } @@ -314,7 +324,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 +334,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 +352,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..40b38a2ecb9c7 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -16,7 +16,7 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.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'; @@ -68,13 +68,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 +129,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 +157,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 +212,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..0eb5d74364fca 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -39,15 +39,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..ae2df6ac89dc5 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -133,7 +133,7 @@ abstract class AbstractUpdateService implements IUpdateService { // noop } - async disableProgressiveReleases(): Promise { + async setInternalOrg(_internalOrg: string | undefined): Promise { // noop - not applicable for snap } @@ -176,7 +176,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..d02d7c3b8d80d 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -33,6 +33,7 @@ import { asJson, IRequestService } from '../../request/common/request.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; @@ -98,6 +99,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override async initialize(): Promise { + // In the embedded app, skip win32-specific setup (cache paths, telemetry) + // but still run the base initialization to detect available updates. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); + await super.initialize(); + return; + } + if (this.productService.win32VersionedUpdate) { const cachePath = await this.cachePath; app.setPath('appUpdate', cachePath); @@ -188,8 +197,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 +207,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 +219,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 +229,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 +256,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; - return this.requestService.request({ url: update.url }, CancellationToken.None) + return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, CancellationToken.None) .then(context => { // Get total size from Content-Length header const contentLengthHeader = context.res.headers['content-length']; diff --git a/src/vs/platform/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/test/electron-main/cdpAccessibilityDomain.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts index 5085922319585..f4ec49d643be5 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; -import { AXNode, AXProperty, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; +import { AXNode, AXProperty, AXPropertyName, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; suite('CDP Accessibility Domain', () => { @@ -17,10 +17,9 @@ suite('CDP Accessibility Domain', () => { return { type, value }; } - function createAXProperty(name: string, value: any, type: AXValueType = 'string'): AXProperty { + function createAXProperty(name: AXPropertyName, value: any, type: AXValueType = 'string'): AXProperty { return { - // eslint-disable-next-line local/code-no-any-casts - name: name as any, + name, value: createAXValue(type, value) }; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 7455376e3f45c..07551319546ce 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { getMarks, mark } from '../../../base/common/performance.js'; -import { isTahoeOrNewer, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { isTahoeOrNewer, isLinux, isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { release } from 'os'; @@ -702,11 +702,16 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this.windowState = state; this.logService.trace('window#ctor: using window state', state); - const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, { + const webPreferences: electron.WebPreferences = { preload: FileAccess.asFileUri('vs/base/parts/sandbox/electron-browser/preload.js').fsPath, additionalArguments: [`--vscode-window-config=${this.configObjectUrl.resource.toString()}`], - v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none', - }); + v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none' + }; + if ((process as INodeProcess).isEmbeddedApp) { + webPreferences.backgroundThrottling = false; // disable for sub-app + } + + const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, webPreferences); // Create the browser window mark('code/willCreateCodeBrowserWindow'); diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 50b1321e83e30..75bcee0c678ad 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -7,7 +7,7 @@ import electron, { Display, Rectangle } from 'electron'; import { Color } from '../../../base/common/color.js'; import { Event } from '../../../base/common/event.js'; import { join } from '../../../base/common/path.js'; -import { IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, IProcessEnvironment, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; @@ -177,8 +177,15 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt if (isLinux) { options.icon = join(environmentMainService.appRoot, 'resources/linux/code.png'); // always on Linux - } else if (isWindows && !environmentMainService.isBuilt) { - options.icon = join(environmentMainService.appRoot, 'resources/win32/code_150x150.png'); // only when running out of sources on Windows + } else if (isWindows) { + if (!environmentMainService.isBuilt) { + options.icon = join(environmentMainService.appRoot, 'resources/win32/code_150x150.png'); // only when running out of sources on Windows + } else if ((process as INodeProcess).isEmbeddedApp) { + // For sub app the proxy executable acts as a launcher to the main executable whose + // icon will be used when creating windows if the following override is not set. + // This avoids sharing icon with the main application. + options.icon = join(environmentMainService.appRoot, 'resources/win32/sessions.ico'); + } } if (isMacintosh) { diff --git a/src/vs/platform/workspace/common/workspace.ts b/src/vs/platform/workspace/common/workspace.ts index a0459d077e6ea..691e1a3bf2e89 100644 --- a/src/vs/platform/workspace/common/workspace.ts +++ b/src/vs/platform/workspace/common/workspace.ts @@ -74,6 +74,11 @@ export interface IWorkspaceContextService { * Returns if the provided resource is inside the workspace or not. */ isInsideWorkspace(resource: URI): boolean; + + /** + * Return `true` if the current workspace has data (e.g. folders or a workspace configuration) that can be sent to the extension host, otherwise `false`. + */ + hasWorkspaceData(): boolean; } export interface IResolvedWorkspace extends IWorkspaceIdentifier, IBaseWorkspace { diff --git a/src/vs/server/node/remoteExtensionManagement.ts b/src/vs/server/node/remoteExtensionManagement.ts index df587cf746828..4a38e83ea25a0 100644 --- a/src/vs/server/node/remoteExtensionManagement.ts +++ b/src/vs/server/node/remoteExtensionManagement.ts @@ -8,6 +8,7 @@ import { ILogService } from '../../platform/log/common/log.js'; import { Emitter, Event } from '../../base/common/event.js'; import { VSBuffer } from '../../base/common/buffer.js'; import { ProcessTimeRunOnceScheduler } from '../../base/common/async.js'; +import { IDisposable } from '../../base/common/lifecycle.js'; function printTime(ms: number): string { let h = 0; @@ -45,6 +46,7 @@ export class ManagementConnection { private _disposed: boolean; private _disconnectRunner1: ProcessTimeRunOnceScheduler; private _disconnectRunner2: ProcessTimeRunOnceScheduler; + private readonly _socketCloseListener: IDisposable; constructor( private readonly _logService: ILogService, @@ -69,11 +71,11 @@ export class ManagementConnection { this._cleanResources(); }, this._reconnectionShortGraceTime); - this.protocol.onDidDispose(() => { + Event.once(this.protocol.onDidDispose)(() => { this._log(`The client has disconnected gracefully, so the connection will be disposed.`); this._cleanResources(); }); - this.protocol.onSocketClose(() => { + this._socketCloseListener = this.protocol.onSocketClose(() => { this._log(`The client has disconnected, will wait for reconnection ${printTime(this._reconnectionGraceTime)} before disposing...`); // The socket has closed, let's give the renderer a certain amount of time to reconnect this._disconnectRunner1.schedule(); @@ -106,6 +108,7 @@ export class ManagementConnection { this._disposed = true; this._disconnectRunner1.dispose(); this._disconnectRunner2.dispose(); + this._socketCloseListener.dispose(); const socket = this.protocol.getSocket(); this.protocol.sendDisconnect(); this.protocol.dispose(); diff --git a/src/vs/server/node/remoteTerminalChannel.ts b/src/vs/server/node/remoteTerminalChannel.ts index a455eba42679c..ab6f98f02561c 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -226,7 +226,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel< const activeWorkspaceFolder = args.activeWorkspaceFolder ? reviveWorkspaceFolder(args.activeWorkspaceFolder) : undefined; const activeFileResource = args.activeFileResource ? URI.revive(uriTransformer.transformIncoming(args.activeFileResource)) : undefined; const customVariableResolver = new CustomVariableResolver(baseEnv, workspaceFolders, activeFileResource, args.resolvedVariables, this._extensionManagementService); - const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, process.env, customVariableResolver); + const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, baseEnv, customVariableResolver); // Get the initial cwd const initialCwd = await terminalEnvironment.getCwd(shellLaunchConfig, os.homedir(), variableResolver, activeWorkspaceFolder?.uri, args.configuration['terminal.integrated.cwd'], this._logService); diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index d37663dfefb4c..1ef575e8637d4 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -86,6 +86,10 @@ import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeM import { IMcpGatewayService, McpGatewayChannelName } from '../../platform/mcp/common/mcpGateway.js'; import { McpGatewayService } from '../../platform/mcp/node/mcpGatewayService.js'; import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'; +import { SandboxHelperChannelName } from '../../platform/sandbox/common/sandboxHelperIpc.js'; +import { ISandboxHelperService } from '../../platform/sandbox/common/sandboxHelperService.js'; +import { SandboxHelperChannel } from '../../platform/sandbox/node/sandboxHelperChannel.js'; +import { SandboxHelperService } from '../../platform/sandbox/node/sandboxHelperService.js'; import { IExtensionGalleryManifestService } from '../../platform/extensionManagement/common/extensionGalleryManifest.js'; import { ExtensionGalleryManifestIPCService } from '../../platform/extensionManagement/common/extensionGalleryManifestServiceIpc.js'; import { IAllowedMcpServersService, IMcpGalleryService, IMcpManagementService } from '../../platform/mcp/common/mcpManagement.js'; @@ -211,6 +215,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken services.set(IExtensionSignatureVerificationService, new SyncDescriptor(ExtensionSignatureVerificationService)); services.set(IAllowedExtensionsService, new SyncDescriptor(AllowedExtensionsService)); services.set(INativeServerExtensionManagementService, new SyncDescriptor(ExtensionManagementService)); + services.set(ISandboxHelperService, new SyncDescriptor(SandboxHelperService)); services.set(INativeMcpDiscoveryHelperService, new SyncDescriptor(NativeMcpDiscoveryHelperService)); services.set(IMcpGatewayService, new SyncDescriptor(McpGatewayService)); @@ -250,6 +255,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken const remoteExtensionsScanner = new RemoteExtensionsScannerService(instantiationService.createInstance(ExtensionManagementCLI, productService.extensionsForceVersionByQuality ?? [], logService), environmentService, userDataProfilesService, extensionsScannerService, logService, extensionGalleryService, languagePackService, extensionManagementService); socketServer.registerChannel(RemoteExtensionsScannerChannelName, new RemoteExtensionsScannerChannel(remoteExtensionsScanner, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); + socketServer.registerChannel(SandboxHelperChannelName, instantiationService.createInstance(SandboxHelperChannel)); socketServer.registerChannel(NativeMcpDiscoveryHelperChannelName, instantiationService.createInstance(NativeMcpDiscoveryHelperChannel, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); socketServer.registerChannel(McpGatewayChannelName, instantiationService.createInstance(McpGatewayChannel, socketServer)); diff --git a/src/vs/server/node/webClientServer.ts b/src/vs/server/node/webClientServer.ts index 7881ad0393d70..028a056717b1e 100644 --- a/src/vs/server/node/webClientServer.ts +++ b/src/vs/server/node/webClientServer.ts @@ -206,7 +206,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/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 9dceeac3a2b24..e3c8ec0b8d1ce 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,94 +1,148 @@ # 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) +├── aiCustomizationDebugPanel.ts # Debug diagnostics panel +├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl ├── customizationCreatorService.ts # AI-guided creation flow ├── mcpListWidget.ts # MCP servers section -├── SPEC.md # Feature specification +├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css +src/vs/workbench/contrib/chat/common/ +└── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter +``` + +The tree view and overview live in `vs/sessions` (sessions window only): + +``` src/vs/sessions/contrib/aiCustomizationTreeView/browser/ ├── aiCustomizationTreeView.contribution.ts # View + actions ├── aiCustomizationTreeView.ts # IDs + menu IDs ├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationTreeViewIcons.ts # Icons -├── SPEC.md # Feature specification +├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) └── media/ └── aiCustomizationTreeView.css ``` ---- +Sessions-specific overrides: -## Service Alignment (Required) +``` +src/vs/sessions/contrib/chat/browser/ +├── aiCustomizationWorkspaceService.ts # Sessions workspace service override +└── promptsService.ts # AgenticPromptsService (CLI user roots) +src/vs/sessions/contrib/sessions/browser/ +├── customizationCounts.ts # Source count utilities (type-aware) +└── customizationsToolbar.contribution.ts # Sidebar customization links +``` -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. +### IAICustomizationWorkspaceService -Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. +The `IAICustomizationWorkspaceService` interface controls per-window behavior: -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) +| Property / Method | Core VS Code | Sessions Window | +|----------|-------------|----------| +| `managementSections` | All sections except Models | Same minus MCP | +| `getStorageSourceFilter(type)` | All sources, no user root filter | Per-type (see below) | +| `isSessionsWindow` | `false` | `true` | +| `activeProjectRoot` | First workspace folder | Active session worktree | -The active worktree comes from `IActiveSessionService` and is the source of truth for any workspace/worktree scoping. +### IStorageSourceFilter -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). +A unified per-type filter controlling which storage sources and user file roots are visible. +Replaces the old `visibleStorageSources`, `getVisibleStorageSources(type)`, and `excludedUserFileRoots`. -## Implemented Experience +```typescript +interface IStorageSourceFilter { + sources: readonly PromptsStorage[]; // Which storage groups to display + includedUserFileRoots?: readonly URI[]; // Allowlist for user roots (undefined = all) +} +``` -### Management Editor (Current) +The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. -- 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. +**Sessions filter behavior by type:** -### Tree View (Current) +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local]` | N/A | +| Prompts | `[local, user]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user]` | `[~/.copilot, ~/.claude, ~/.agents]` | -- 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. +**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter. -### Additional Surfaces (Current) +### AgenticPromptsService (Sessions) -- 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. +Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): ---- +- **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree +- **Built-in prompts**: Discovers bundled `.prompt.md` files from `vs/sessions/prompts/` and surfaces them with `PromptsStorage.builtin` storage type +- **User override**: Built-in prompts are omitted when a user or workspace prompt with the same name exists +- **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility +- **Hook folders**: Falls back to `.github/hooks` in the active worktree -## AI Feature Gating +### Built-in Prompts -All commands and UI must respect `ChatContextKeys.enabled`: +Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. They are: -```typescript -All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. -``` +- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/prompts')` +- Tagged with `PromptsStorage.builtin` storage type +- Shown in a "Built-in" group in the AI Customization tree view and management editor +- Filtered out when a user/workspace prompt shares the same clean name (override behavior) +- Included in storage filters for prompts and CLI-user types + +### Count Consistency + +`customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: + +| Type | Data Source | Notes | +|------|-------------|-------| +| Agents | `getCustomAgents()` | Parsed agents, not raw files | +| Skills | `findAgentSkills()` | Parsed skills with frontmatter | +| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | +| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | +| Hooks | `listPromptFiles()` | Raw hook files | + +### 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.` namespace: -- [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 | diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index fea9a1c370b86..ad54a086c7fb1 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -440,7 +440,7 @@ The `AuxiliaryBarPart` provides a custom `DropdownWithPrimaryActionViewItem` for The `SidebarPart` includes a footer section (35px height) positioned below the pane composite content. The sidebar uses a custom `layout()` override that reduces the content height by `FOOTER_HEIGHT` and renders a `MenuWorkbenchToolBar` driven by `Menus.SidebarFooter`. The footer hosts the account widget (see Section 3.6). -On macOS native, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls, which is hidden in fullscreen mode. +On macOS native with custom titlebar, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls. The spacer is hidden in fullscreen mode and is not created when using native titlebar (since the OS renders traffic lights in its own title bar). --- @@ -640,6 +640,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-03-02 | Fixed macOS sidebar traffic light spacer to only render with custom titlebar; added `!hasNativeTitlebar()` guard to `SidebarPart.createTitleArea()` so the 70px spacer is not created when using native titlebar (traffic lights are in the OS title bar, not overlapping the sidebar) | | 2026-02-20 | Replaced custom `EditorModal` with standard `ModalEditorPart` via `MODAL_GROUP`; main editor part created but hidden; changed `workbench.editor.useModal` from boolean to enum (`off`/`some`/`all`); sessions config uses `all`; removed `editorModal.ts` and editor modal CSS | | 2026-02-17 | Added `-webkit-app-region: drag` to sidebar title area so it can be used to drag the window; interactive children (actions, composite bar, labels) marked `no-drag`; CSS rules scoped to `.agent-sessions-workbench` in `parts/media/sidebarPart.css` | | 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 39f7697ed7280..c9bf983b7c963 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -9,12 +9,14 @@ import { KeyCode, KeyMod } from '../../base/common/keyCodes.js'; import { localize, localize2 } from '../../nls.js'; import { Categories } from '../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuRegistry, registerAction2 } from '../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { Menus } from './menus.js'; import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; // Register Icons const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); @@ -50,10 +52,10 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeft, + id: Menus.TitleBarLeftLayout, group: 'navigation', order: 0, - when: IsAuxiliaryWindowContext.toNegated() + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -102,10 +104,10 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRight, + id: Menus.TitleBarRightLayout, group: 'navigation', order: 10, - when: IsAuxiliaryWindowContext.toNegated() + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -163,7 +165,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..0a57fa73f7d0c 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -43,14 +43,42 @@ background-color: var(--vscode-sideBar-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: 8px; + padding-right: 8px; + box-sizing: border-box; +} + +.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { + max-width: 950px; + margin: 0 auto; + padding-left: 8px; + padding-right: 8px; + box-sizing: border-box; +} + /* ---- Chat Input ---- */ .agent-sessions-workbench .interactive-session .chat-input-container { - border-radius: 8px !important; + border-radius: var(--vscode-cornerRadius-large) !important; } .agent-sessions-workbench .interactive-session .interactive-input-part { - margin: 0 8px !important; + width: 100%; + max-width: 950px; + margin: 0 auto !important; display: inherit !important; - padding: 4px 0 8px 0px !important; + /* Align with panel (terminal) card margin */ + padding: 4px 8px 6px 8px !important; + box-sizing: border-box; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index ed06a0221d951..c322fba968b6b 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'), diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index c7dbddc3e33c3..cef38cedc82ec 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -12,8 +12,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_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsSidebarBorder } 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'; @@ -81,7 +82,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 300); + return Math.max(width, 340); } readonly priority = LayoutPriority.Low; @@ -105,7 +106,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { { hasTitle: true, trailingSeparator: false, - borderWidth: () => (this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder)) ? 1 : 0, + borderWidth: () => (this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder)) ? 1 : 0, }, AuxiliaryBarPart.activeViewSettingsKey, ActiveAuxiliaryContext.bindTo(contextKeyService), @@ -141,7 +142,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { // 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.setProperty('--part-border-color', this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder) || 'transparent'); container.style.backgroundColor = 'transparent'; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index 9a74bb7021bd0..ebafc12e5c3b5 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -34,8 +34,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { static readonly placeholderViewContainersKey = 'workbench.chatbar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; - // Use the side bar dimensions - override readonly minimumWidth: number = 170; + override readonly minimumWidth: number = 300; override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; @@ -44,21 +43,6 @@ export class ChatBarPart extends AbstractPaneCompositePart { return this.layoutService.mainContainerDimension.height * 0.4; } - get preferredWidth(): number | undefined { - const activeComposite = this.getActivePaneComposite(); - - if (!activeComposite) { - return undefined; - } - - const width = activeComposite.getOptimalWidth(); - if (typeof width !== 'number') { - return undefined; - } - - return Math.max(width, 300); - } - readonly priority = LayoutPriority.High; constructor( diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 22273655103c7..146ed67fa619d 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -3,8 +3,81 @@ * 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 .part.titlebar > .sessions-titlebar-container > .titlebar-right { + order: 2; + width: fit-content; + flex-grow: 0; + justify-content: flex-end; + margin-right: 10px; +} + +/* 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 between session actions and layout actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-layout-actions-container:not(.has-no-actions)::before { + content: ''; + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-disabledForeground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .codicon { + color: inherit; +} + +.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,25 +90,22 @@ order: 2; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { display: flex; justify-content: center; align-items: center; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .codicon { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .codicon { color: inherit; } -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { display: flex; } -/* TODO: Hack to avoid flicker when sidebar becomes visible. - * The contribution swaps the menu item synchronously, but the toolbar - * re-render is async, causing a brief flash. Hide the container via - * CSS when sidebar is visible (nosidebar class is removed synchronously). */ -.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { +/* Hide the entire titlebar left when the sidebar is visible */ +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left { display: none !important; } diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 867760bd11228..2fccc865f15e1 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_BACKGROUND, 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 { sessionsSidebarBorder } 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'; @@ -129,7 +130,7 @@ export class PanelPart extends AbstractPaneCompositePart { // 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-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.setProperty('--part-border-color', this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder) || 'transparent'); container.style.backgroundColor = 'transparent'; // Clear inline borders - the card appearance uses CSS border-radius instead diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index 5f8cce31cf807..75eb74869ddc4 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -38,6 +38,8 @@ import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/acti 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'; /** * Sidebar part specifically for agent sessions workbench. @@ -103,6 +105,7 @@ export class SidebarPart extends AbstractPaneCompositePart { @IContextKeyService contextKeyService: IContextKeyService, @IExtensionService extensionService: IExtensionService, @IMenuService menuService: IMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super( Parts.SIDEBAR_PART, @@ -117,7 +120,7 @@ export class SidebarPart extends AbstractPaneCompositePart { ViewContainerLocation.Sidebar, Extensions.Viewlets, Menus.SidebarTitle, - Menus.TitleBarLeft, + Menus.TitleBarLeftLayout, notificationService, storageService, contextMenuService, @@ -151,7 +154,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%'; diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 8eab246ab644e..18c2d2867f08d 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'; @@ -132,7 +132,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 +185,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, @@ -204,13 +204,22 @@ export class TitlebarPart extends Part implements ITitlebarPart { })); // 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, { + const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-layout-actions-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { contextMenu: Menus.TitleBarContext, 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 +263,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { }); } - private lastLayoutDimension: Dimension | undefined; - get hasZoomableElements(): boolean { return true; // sessions titlebar always has command center and toolbar actions } @@ -268,7 +275,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { } override layout(width: number, height: number): void { - this.lastLayoutDimension = new Dimension(width, height); this.updateLayout(); super.layoutContents(width, height); } @@ -281,24 +287,6 @@ export class TitlebarPart extends Part implements ITitlebarPart { const zoomFactor = getZoomFactor(getWindow(this.element)); this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); - - this.updateCenterOffset(); - } - - private updateCenterOffset(): void { - if (!this.centerContent || !this.lastLayoutDimension) { - return; - } - - // Center the command center relative to the viewport. - // The titlebar only covers the right section (sidebar is to the left), - // so we shift the center content left by half the sidebar width - // using a negative margin. - const windowWidth = this.layoutService.mainContainerDimension.width; - const titlebarWidth = this.lastLayoutDimension.width; - const leftOffset = windowWidth - titlebarWidth; - this.centerContent.style.marginLeft = leftOffset > 0 ? `${-leftOffset / 2}px` : ''; - this.centerContent.style.marginRight = leftOffset > 0 ? `${leftOffset / 2}px` : ''; } focus(): void { diff --git a/src/vs/sessions/browser/web.factory.ts b/src/vs/sessions/browser/web.factory.ts new file mode 100644 index 0000000000000..e33129acea8a8 --- /dev/null +++ b/src/vs/sessions/browser/web.factory.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbench, IWorkbenchConstructionOptions } from '../../workbench/browser/web.api.js'; +import { SessionsBrowserMain } from './web.main.js'; +import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { mark } from '../../base/common/performance.js'; +import { DeferredPromise } from '../../base/common/async.js'; + +const workbenchPromise = new DeferredPromise(); + +/** + * Creates the Sessions workbench with the provided options in the provided container. + */ +export function create(domElement: HTMLElement, options: IWorkbenchConstructionOptions): IDisposable { + + mark('code/didLoadWorkbenchMain'); + + let instantiatedWorkbench: IWorkbench | undefined = undefined; + new SessionsBrowserMain(domElement, options).open().then(workbench => { + instantiatedWorkbench = workbench; + workbenchPromise.complete(workbench); + }); + + return toDisposable(() => { + if (instantiatedWorkbench) { + instantiatedWorkbench.shutdown(); + } else { + workbenchPromise.p.then(w => w.shutdown()); + } + }); +} diff --git a/src/vs/sessions/browser/web.main.ts b/src/vs/sessions/browser/web.main.ts new file mode 100644 index 0000000000000..0c57fb902f6c8 --- /dev/null +++ b/src/vs/sessions/browser/web.main.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { BrowserMain, IBrowserMainWorkbench } from '../../workbench/browser/web.main.js'; +import { Workbench as SessionsWorkbench } from './workbench.js'; + +export class SessionsBrowserMain extends BrowserMain { + + protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { + console.log('[Sessions Web] Creating Sessions workbench (not standard workbench)'); + return new SessionsWorkbench(domElement, undefined, serviceCollection, logService); + } +} diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 66e41f3a74276..b94d04f64be03 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 = 340; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; @@ -902,6 +896,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + LayoutClasses.STATUSBAR_HIDDEN, // sessions window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined ]); } diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index d95d7411ac4cf..76d7136d14c5a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -6,12 +6,6 @@ import { localize } from '../../nls.js'; import { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; -//#region < --- Welcome --- > - -export const SessionsWelcomeCompleteContext = new RawContextKey('sessionsWelcomeComplete', false, localize('sessionsWelcomeComplete', "Whether the sessions welcome setup is complete")); - -//#endregion - //#region < --- Chat Bar --- > export const ActiveChatBarContext = new RawContextKey('activeChatBar', '', localize('activeChatBar', "The identifier of the active chat bar panel")); @@ -19,3 +13,9 @@ export const ChatBarFocusContext = new RawContextKey('chatBarFocus', fa export const ChatBarVisibleContext = new RawContextKey('chatBarVisible', false, localize('chatBarVisible', "Whether the chat bar is visible")); //#endregion + +//#region < --- Welcome --- > + +export const SessionsWelcomeVisibleContext = new RawContextKey('sessionsWelcomeVisible', false, localize('sessionsWelcomeVisible', "Whether the sessions welcome overlay is visible")); + +//#endregion diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index 4d17842818037..2f928d12aecbb 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../nls.js'; -import { registerColor } from '../../platform/theme/common/colorUtils.js'; -import { contrastBorder } from '../../platform/theme/common/colorRegistry.js'; +import { registerColor, transparent } from '../../platform/theme/common/colorUtils.js'; +import { contrastBorder, iconForeground } from '../../platform/theme/common/colorRegistry.js'; import { Color } from '../../base/common/color.js'; +import { buttonBackground } from '../../platform/theme/common/colors/inputColors.js'; import { SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND } from '../../workbench/common/theme.js'; // Sessions sidebar background color @@ -48,3 +49,23 @@ export const chatBarTitleForeground = registerColor( SIDE_BAR_FOREGROUND, localize('chatBarTitle.foreground', 'Foreground color of the chat bar title area in the agent sessions window.') ); + +// Agent feedback input widget border color +export const agentFeedbackInputWidgetBorder = registerColor( + 'agentFeedbackInputWidget.border', + { dark: transparent(iconForeground, 0.8), light: transparent(iconForeground, 0.8), hcDark: contrastBorder, hcLight: contrastBorder }, + localize('agentFeedbackInputWidget.border', 'Border color of the agent feedback input widget shown in the editor.') +); + +// Sessions update button colors +export const sessionsUpdateButtonDownloadingBackground = registerColor( + 'sessionsUpdateButton.downloadingBackground', + transparent(buttonBackground, 0.4), + localize('sessionsUpdateButton.downloadingBackground', 'Background color of the update button to show download progress in the agent sessions window.') +); + +export const sessionsUpdateButtonDownloadedBackground = registerColor( + 'sessionsUpdateButton.downloadedBackground', + transparent(buttonBackground, 0.7), + localize('sessionsUpdateButton.downloadedBackground', 'Background color of the update button when download is complete in the agent sessions window.') +); diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index cbff23edc4c94..c4d28d533855d 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -12,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -24,6 +24,13 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { URI } from '../../../../base/common/uri.js'; +import { UpdateHoverWidget } from './updateHoverWidget.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); @@ -81,20 +88,29 @@ MenuRegistry.appendMenuItem(AccountMenu, { // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); -class AccountWidget extends ActionViewItem { +export class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly updateHoverWidget: UpdateHoverWidget; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( action: IAction, options: IBaseActionViewItemOptions, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IUpdateService private readonly updateService: IUpdateService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IProductService private readonly productService: IProductService, + @IOpenerService private readonly openerService: IOpenerService, + @IDialogService private readonly dialogService: IDialogService, + @IHostService private readonly hostService: IHostService, ) { super(undefined, action, { ...options, icon: false, label: false }); + this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); } protected override getTooltip(): string | undefined { @@ -119,14 +135,33 @@ class AccountWidget extends ActionViewItem { })); this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); + // Update button (right) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button', 'sidebar-action-button'); + this.viewItemDisposables.add(this.updateHoverWidget.attachTo(this.updateButton.element)); + this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); this.viewItemDisposables.add(this.accountButton.onDidClick(e => { e?.preventDefault(); e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); } private showAccountMenu(anchor: HTMLElement): void { @@ -154,101 +189,100 @@ class AccountWidget extends ActionViewItem { : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } + private updateUpdateButton(): void { + if (!this.updateButton) { + return; + } - override onClick(): void { - // Handled by custom click handlers - } -} - -class UpdateWidget extends ActionViewItem { - - private updateButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); + const state = this.updateService.state; - constructor( - action: IAction, - options: IBaseActionViewItemOptions, - @IUpdateService private readonly updateService: IUpdateService, - ) { - super(undefined, action, { ...options, icon: false, label: false }); - } + // In the embedded app, updates are detected but cannot be installed directly. + // Show a hint button to update via VS Code only when an update is actually available. + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.classList.remove('account-widget-update-button-ready'); + this.updateButton.element.classList.add('account-widget-update-button-hint'); + this.updateButton.enabled = true; + this.updateButton.label = localize('updateAvailable', "Update Available"); + this.updateButton.element.title = localize('updateInVSCodeHover', "Updates are managed by VS Code. Click to open VS Code."); + return; + } - protected override getTooltip(): string | undefined { - return undefined; - } + if (this.shouldHideUpdateButton(state.type)) { + this.clearUpdateButtonStyling(); + this.updateButton.element.classList.add('hidden'); + return; + } - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('update-widget', 'sidebar-action'); + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.enabled = state.type === StateType.Ready; + this.updateButton.label = this.getUpdateProgressMessage(state.type); - const updateContainer = append(container, $('.update-widget-action')); - this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); - this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); - - this.updateUpdateButton(); - this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); - } + if (state.type === StateType.Ready) { + this.updateButton.element.classList.add('account-widget-update-button-ready'); + return; + } - private isUpdateReady(): boolean { - return this.updateService.state.type === StateType.Ready; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } - private isUpdatePending(): boolean { - const type = this.updateService.state.type; - return type === StateType.AvailableForDownload - || type === StateType.CheckingForUpdates - || type === StateType.Downloading - || type === StateType.Downloaded - || type === StateType.Updating - || type === StateType.Overwriting; + private shouldHideUpdateButton(type: StateType): boolean { + return type === StateType.Uninitialized + || type === StateType.Idle + || type === StateType.Disabled + || type === StateType.CheckingForUpdates; } - private updateUpdateButton(): void { - if (!this.updateButton) { - return; - } - - const state = this.updateService.state; - if (this.isUpdatePending() && !this.isUpdateReady()) { - this.updateButton.enabled = false; - this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - } else { - this.updateButton.enabled = true; - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + private clearUpdateButtonStyling(): void { + if (this.updateButton) { + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } } private getUpdateProgressMessage(type: StateType): string { switch (type) { - case StateType.CheckingForUpdates: - return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Ready: + return localize('update', "Update"); + case StateType.AvailableForDownload: case StateType.Downloading: - return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading..."); case StateType.Downloaded: - return localize('installingUpdate', "Installing Update..."); + return localize('installingUpdate', "Installing..."); case StateType.Updating: return localize('updatingApp', "Updating..."); - case StateType.Overwriting: - return localize('overwritingUpdate', "Downloading Update..."); default: return localize('updating', "Updating..."); } } private async update(): Promise { + const state = this.updateService.state; + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + const { confirmed } = await this.dialogService.confirm({ + message: localize('updateFromVSCode.title', "Update from VS Code"), + detail: localize('updateFromVSCode.detail', "This will close the Sessions app and open VS Code so you can install the update.\n\nLaunch Sessions again after the update is complete."), + primaryButton: localize('updateFromVSCode.open', "Close and Open VS Code"), + }); + if (confirmed) { + await this.openVSCode(); + await this.hostService.close(); + } + return; + } await this.updateService.quitAndInstall(); } + private async openVSCode(): Promise { + await this.openerService.open(URI.from({ + scheme: this.productService.urlProtocol, + query: 'windowId=_blank', + }), { openExternal: true }); + } + + override onClick(): void { // Handled by custom click handlers } @@ -271,11 +305,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); - const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; - this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(UpdateWidget, action, options); - }, undefined)); - // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -295,30 +324,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu } })); - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: sessionsUpdateWidgetAction, - title: localize2('sessionsUpdateWidget', 'Sessions Update'), - menu: { - id: Menus.SidebarFooter, - group: 'navigation', - order: 0, - when: ContextKeyExpr.or( - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), - ) - } - }); - } - async run(): Promise { - // Handled by the custom view item - } - })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 01bdd2c100b03..3e852ea53ba47 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,6 +3,22 @@ * 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; +} + /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -10,9 +26,132 @@ flex: 1; } +.account-widget-account { + overflow: hidden; + min-width: 0; + flex: 1; +} + /* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +/* Boxed hint style for embedded app update indicator — outlined, no fill */ +.account-widget-update .account-widget-update-button.account-widget-update-button-hint { + background-color: transparent !important; + color: var(--vscode-button-foreground) !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; +} + +.account-widget-account .account-widget-account-button { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > span:last-child { + flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-widget-account .account-widget-account-button > span:last-child { flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css new file mode 100644 index 0000000000000..6291d8e292250 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-update-hover { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + padding: 12px 16px; +} + +.sessions-update-hover-header { + font-weight: 600; + font-size: 13px; +} + +/* Progress bar track */ +.sessions-update-hover-progress-track { + height: 4px; + border-radius: 2px; + background-color: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); + overflow: hidden; +} + +/* Progress bar fill */ +.sessions-update-hover-progress-fill { + height: 100%; + border-radius: 2px; + background-color: var(--vscode-progressBar-background, #0078d4); + transition: width 0.2s ease; +} + +/* Details grid */ +.sessions-update-hover-grid { + display: grid; + grid-template-columns: auto auto auto auto; + column-gap: 8px; + row-gap: 2px; + font-size: 12px; + align-items: baseline; +} + +.sessions-update-hover-label { + color: var(--vscode-descriptionForeground); +} + +/* Version number emphasis */ +.sessions-update-hover-version { + color: var(--vscode-textLink-foreground); +} + +/* Compact age label */ +.sessions-update-hover-age { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +/* Commit hashes - subtle */ +.sessions-update-hover-commit { + color: var(--vscode-descriptionForeground); + font-family: var(--monaco-monospace-font); + font-size: 11px; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts new file mode 100644 index 0000000000000..fc80636b0a8f7 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Downloading, IUpdate, IUpdateService, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import './media/updateHoverWidget.css'; + +export class UpdateHoverWidget { + + constructor( + private readonly updateService: IUpdateService, + private readonly productService: IProductService, + private readonly hoverService: IHoverService, + ) { } + + attachTo(target: HTMLElement) { + return this.hoverService.setupDelayedHover( + target, + () => ({ + content: this.createHoverContent(), + position: { hoverPosition: HoverPosition.RIGHT }, + appearance: { showPointer: true } + }), + { groupId: 'sessions-account-update' } + ); + } + + createHoverContent(state: State = this.updateService.state): HTMLElement { + const update = this.getUpdateFromState(state); + const currentVersion = this.productService.version ?? localize('unknownVersion', "Unknown"); + const targetVersion = update?.productVersion ?? update?.version ?? localize('unknownVersion', "Unknown"); + const currentCommit = this.productService.commit; + const targetCommit = update?.version; + const progressPercent = this.getUpdateProgressPercent(state); + + const container = document.createElement('div'); + container.classList.add('sessions-update-hover'); + + // Header: e.g. "Downloading VS Code Insiders" + const header = document.createElement('div'); + header.classList.add('sessions-update-hover-header'); + header.textContent = this.getUpdateHeaderLabel(state.type); + container.appendChild(header); + + // Progress bar + if (progressPercent !== undefined) { + const progressTrack = document.createElement('div'); + progressTrack.classList.add('sessions-update-hover-progress-track'); + const progressFill = document.createElement('div'); + progressFill.classList.add('sessions-update-hover-progress-fill'); + progressFill.style.width = `${progressPercent}%`; + progressTrack.appendChild(progressFill); + container.appendChild(progressTrack); + } + + // Version info grid + const detailsGrid = document.createElement('div'); + detailsGrid.classList.add('sessions-update-hover-grid'); + + const currentDate = this.productService.date ? new Date(this.productService.date) : undefined; + const currentAge = currentDate ? this.formatCompactAge(currentDate.getTime()) : undefined; + const newAge = update?.timestamp ? this.formatCompactAge(update.timestamp) : undefined; + + this.appendGridRow(detailsGrid, localize('updateHoverCurrentVersionLabel', "Current"), currentVersion, currentAge, currentCommit); + this.appendGridRow(detailsGrid, localize('updateHoverNewVersionLabel', "New"), targetVersion, newAge, targetCommit); + + container.appendChild(detailsGrid); + + return container; + } + + private appendGridRow(grid: HTMLElement, label: string, version: string, age?: string, commit?: string): void { + const labelEl = document.createElement('span'); + labelEl.classList.add('sessions-update-hover-label'); + labelEl.textContent = label; + grid.appendChild(labelEl); + + const versionEl = document.createElement('span'); + versionEl.classList.add('sessions-update-hover-version'); + versionEl.textContent = version; + grid.appendChild(versionEl); + + const ageEl = document.createElement('span'); + ageEl.classList.add('sessions-update-hover-age'); + ageEl.textContent = age ?? ''; + grid.appendChild(ageEl); + + const commitEl = document.createElement('span'); + commitEl.classList.add('sessions-update-hover-commit'); + commitEl.textContent = commit ? commit.substring(0, 7) : ''; + grid.appendChild(commitEl); + } + + private formatCompactAge(timestamp: number): string { + const seconds = Math.round((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return localize('compactAgeNow', "now"); + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return localize('compactAgeMinutes', "{0}m ago", minutes); + } + const hours = Math.round(seconds / 3600); + if (hours < 24) { + return localize('compactAgeHours', "{0}h ago", hours); + } + const days = Math.round(seconds / 86400); + if (days < 7) { + return localize('compactAgeDays', "{0}d ago", days); + } + const weeks = Math.round(days / 7); + if (weeks < 5) { + return localize('compactAgeWeeks', "{0}w ago", weeks); + } + const months = Math.round(days / 30); + return localize('compactAgeMonths', "{0}mo ago", months); + } + + private getUpdateFromState(state: State): IUpdate | undefined { + switch (state.type) { + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + case StateType.Overwriting: + case StateType.Updating: + return state.update; + case StateType.Downloading: + return state.update; + default: + return undefined; + } + } + + /** + * Returns progress as a percentage (0-100), or undefined if progress is not applicable. + */ + private getUpdateProgressPercent(state: State): number | undefined { + switch (state.type) { + case StateType.Downloading: { + const downloadingState = state as Downloading; + if (downloadingState.downloadedBytes !== undefined && downloadingState.totalBytes && downloadingState.totalBytes > 0) { + return Math.min(100, Math.round((downloadingState.downloadedBytes / downloadingState.totalBytes) * 100)); + } + return 0; + } + case StateType.Updating: { + const updatingState = state as Updating; + if (updatingState.currentProgress !== undefined && updatingState.maxProgress && updatingState.maxProgress > 0) { + return Math.min(100, Math.round((updatingState.currentProgress / updatingState.maxProgress) * 100)); + } + return 0; + } + case StateType.Downloaded: + case StateType.Ready: + return 100; + case StateType.AvailableForDownload: + case StateType.Overwriting: + return 0; + default: + return undefined; + } + } + + private getUpdateHeaderLabel(type: StateType): string { + const productName = this.productService.nameShort; + switch (type) { + case StateType.Ready: + return localize('updateReady', "{0} Update Ready", productName); + case StateType.AvailableForDownload: + return localize('downloadAvailable', "{0} Update Available", productName); + case StateType.Downloading: + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading {0}", productName); + case StateType.Downloaded: + return localize('installingUpdate', "Installing {0}", productName); + case StateType.Updating: + return localize('updatingApp', "Updating {0}", productName); + default: + return localize('updating', "Updating {0}", productName); + } + } +} diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts new file mode 100644 index 0000000000000..143b89aad169b --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICopilotTokenInfo, IDefaultAccount, IPolicyData } from '../../../../../base/common/defaultAccount.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State, UpdateType } from '../../../../../platform/update/common/update.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AccountWidget } from '../../browser/account.contribution.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// Import the CSS +import '../../../../browser/media/sidebarActionButton.css'; +import '../../browser/media/accountWidget.css'; + +const mockUpdate = { version: '1.0.0' }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function createMockDefaultAccountService(accountPromise: Promise): IDefaultAccountService { + const onDidChangeDefaultAccount = new Emitter(); + const onDidChangePolicyData = new Emitter(); + const onDidChangeCopilotTokenInfo = new Emitter(); + const service: IDefaultAccountService = { + _serviceBrand: undefined, + onDidChangeDefaultAccount: onDidChangeDefaultAccount.event, + onDidChangePolicyData: onDidChangePolicyData.event, + onDidChangeCopilotTokenInfo: onDidChangeCopilotTokenInfo.event, + policyData: null, + copilotTokenInfo: null, + getDefaultAccount: () => accountPromise, + getDefaultAccountAuthenticationProvider: () => ({ id: 'github', name: 'GitHub', enterprise: false }), + setDefaultAccountProvider: () => { }, + refresh: () => accountPromise, + signIn: async () => null, + signOut: async () => { }, + }; + return service; +} + +function renderAccountWidget(ctx: ComponentFixtureContext, state: State, accountPromise: Promise): void { + ctx.container.style.padding = '16px'; + ctx.container.style.width = '340px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const mockUpdateService = createMockUpdateService(state); + const mockAccountService = createMockDefaultAccountService(accountPromise); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: registerWorkbenchServices, + }); + + const action = ctx.disposableStore.add(new Action('sessions.action.accountWidget', 'Sessions Account')); + const contextMenuService = instantiationService.get(IContextMenuService); + const menuService = instantiationService.get(IMenuService); + const contextKeyService = instantiationService.get(IContextKeyService); + const hoverService = instantiationService.get(IHoverService); + const productService = instantiationService.get(IProductService); + const openerService = instantiationService.get(IOpenerService); + const dialogService = instantiationService.get(IDialogService); + const hostService = instantiationService.get(IHostService); + const widget = new AccountWidget(action, {}, mockAccountService, mockUpdateService, contextMenuService, menuService, contextKeyService, hoverService, productService, openerService, dialogService, hostService); + ctx.disposableStore.add(widget); + widget.render(ctx.container); +} + +const signedInAccount: IDefaultAccount = { + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, + accountName: 'avery.long.account.name@example.com', + sessionId: 'session-id', + enterprise: false, +}; + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + LoadingSignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), new Promise(() => { })), + }), + + SignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(null)), + }), + + SignedInNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(signedInAccount)), + }), + + CheckingForUpdatesHidden: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.CheckingForUpdates(true), Promise.resolve(signedInAccount)), + }), + + Ready: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Ready(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + AvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.AvailableForDownload(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Downloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000), Promise.resolve(signedInAccount)), + }), + + DownloadedInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloaded(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + Updating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Updating(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Overwriting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Overwriting(mockUpdate, true), Promise.resolve(signedInAccount)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts new file mode 100644 index 0000000000000..6ca47e2ee1b88 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { UpdateHoverWidget } from '../../browser/updateHoverWidget.js'; + +const mockUpdate = { version: 'a1b2c3d4e5f6', productVersion: '1.100.0', timestamp: Date.now() - 2 * 60 * 60 * 1000 }; +const mockUpdateSameVersion = { version: 'a1b2c3d4e5f6', productVersion: '1.99.0', timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function renderHoverWidget(ctx: ComponentFixtureContext, state: State): void { + ctx.container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + }); + + const updateService = createMockUpdateService(state); + const productService = new class extends mock() { + override readonly version = '1.99.0'; + override readonly nameShort = 'VS Code Insiders'; + override readonly commit = 'f0e1d2c3b4a5'; + override readonly date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + }; + const hoverService = instantiationService.get(IHoverService); + const widget = new UpdateHoverWidget(updateService, productService, hoverService); + ctx.container.appendChild(widget.createHoverContent(state)); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + UpdateHoverReady: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdate, true, false)), + }), + + UpdateHoverAvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.AvailableForDownload(mockUpdate)), + }), + + UpdateHoverDownloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), + }), + + UpdateHoverInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloaded(mockUpdate, true, false)), + }), + + UpdateHoverUpdating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Updating(mockUpdate, 40, 100)), + }), + + UpdateHoverSameVersion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdateSameVersion, true, false)), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7f5708a85d8d0..7ff02612cc13f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -5,7 +5,6 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; -import './agentFeedbackLineDecorationContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index e45f96e488d06..04e66cf8dd099 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -74,6 +74,7 @@ export class AgentFeedbackAttachmentContribution extends Disposable { text: f.text, resourceUri: f.resourceUri, range: f.range, + codeSelection: this._snippetCache.get(f.id), })), value, }; 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..c16e39d3bde5c 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -6,24 +6,32 @@ 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 { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments } from './sessionEditorComments.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); abstract class AgentFeedbackEditorAction extends Action2 { @@ -37,16 +45,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 agentSessionsService = accessor.get(IAgentSessionsService); + const codeReviewService = accessor.get(ICodeReviewService); + + 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, agentSessionsService) + ?? agentFeedbackService.getMostRecentSessionForResource(candidate); + if (!sessionResource) { + continue; + } - const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); - const sessionResource = candidates - .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) - .find((value): value is URI => !!value); - if (!sessionResource) { - return; + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); + if (comments.length > 0) { + return this.runWithSession(accessor, sessionResource); + } } - - return this.runWithSession(accessor, sessionResource); } abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; @@ -65,7 +90,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 0, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -74,9 +99,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; } @@ -114,27 +141,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 feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); - if (!feedback) { + const codeReviewService = accessor.get(ICodeReviewService); + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); + + 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 +179,7 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -177,6 +204,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..1f881cc12231a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -11,13 +11,18 @@ import { EditorContributionInstantiation, registerEditorContribution } from '../ import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { 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 { 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 +35,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 +64,54 @@ class AgentFeedbackInputWidget implements IOverlayWidget { this._measureElement.classList.add('agent-feedback-input-measure'); this._domNode.appendChild(this._measureElement); + // Action bar with add/submit actions + const actionsContainer = document.createElement('div'); + actionsContainer.classList.add('agent-feedback-input-actions'); + this._domNode.appendChild(actionsContainer); + + this._addAction = new Action( + 'agentFeedback.add', + localize('agentFeedback.add', "Add Feedback (Enter)"), + ThemeIcon.asClassName(Codicon.plus), + false, + () => { this._onDidTriggerAdd.fire(); return Promise.resolve(); } + ); + + this._addAndSubmitAction = new Action( + 'agentFeedback.addAndSubmit', + localize('agentFeedback.addAndSubmit', "Add Feedback and Submit (Alt+Enter)"), + ThemeIcon.asClassName(Codicon.send), + false, + () => { this._onDidTriggerAddAndSubmit.fire(); return Promise.resolve(); } + ); + + this._actionBar = new ActionBar(actionsContainer); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + + // Toggle to alt action when Alt key is held + const modifierKeyEmitter = ModifierKeyEmitter.getInstance(); + modifierKeyEmitter.event(status => { + this._updateActionForAlt(status.altKey); + }); + this._editor.applyFontInfo(this._inputElement); this._editor.applyFontInfo(this._measureElement); - this._lineHeight = this._editor.getOption(EditorOption.lineHeight); + this._lineHeight = 22; + this._inputElement.style.lineHeight = `${this._lineHeight}px`; + } + + private _isShowingAlt = false; + + private _updateActionForAlt(altKey: boolean): void { + if (altKey && !this._isShowingAlt) { + this._isShowingAlt = true; + this._actionBar.clear(); + this._actionBar.push(this._addAndSubmitAction, { icon: true, label: false, keybinding: localize('altEnter', "Alt+Enter") }); + } else if (!altKey && this._isShowingAlt) { + this._isShowingAlt = false; + this._actionBar.clear(); + this._actionBar.push(this._addAction, { icon: true, label: false, keybinding: localize('enter', "Enter") }); + } } getId(): string { @@ -86,6 +145,7 @@ class AgentFeedbackInputWidget implements IOverlayWidget { clearInput(): void { this._inputElement.value = ''; + this._updateActionEnabled(); this._autoSize(); } @@ -93,6 +153,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 +179,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 +196,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()); @@ -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,11 +263,17 @@ 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; } @@ -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,25 @@ 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; + } + + // 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 +392,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 +415,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 +434,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); + this._hideAndRefocusEditor(); + return true; + } + + private _addFeedbackAndSubmit(): void { + if (!this._widget) { + return; + } + + const text = this._widget.inputElement.value.trim(); if (!text) { return; } @@ -331,9 +483,9 @@ 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); } private _updatePosition(): void { @@ -393,6 +545,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..7780c40acab20 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'; 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; @@ -145,6 +147,8 @@ class AgentFeedbackOverlayController { @IAgentSessionsService agentSessionsService: IAgentSessionsService, @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); + 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/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 5232f8633eef4..09ccece771785 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -5,9 +5,11 @@ 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 { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; @@ -19,46 +21,20 @@ 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 { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.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 { 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'; /** * Widget that displays agent feedback comments for a group of nearby feedback items. @@ -72,7 +48,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 +62,11 @@ 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, ) { super(); @@ -115,12 +92,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 +126,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._toggleExpanded(); })); - // Dismiss button click - this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => { - e.stopPropagation(); - this._dismiss(); - })); } private _toggleExpanded(): void { @@ -170,27 +136,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid } } - private _dismiss(): void { - // Remove all feedback items in this widget from the service - for (const feedback of this._feedbackItems) { - this._agentFeedbackService.removeFeedback(this._sessionResource, feedback.id); - } - - this._domNode.classList.add('fadeOut'); - - const dispose = () => { - this.dispose(); - }; - - const handle = setTimeout(dispose, 150); - this._domNode.addEventListener('animationend', () => { - clearTimeout(handle); - dispose(); - }, { once: true }); - } - private _updateTitle(): void { - const count = this._feedbackItems.length; + const count = this._commentItems.length; if (count === 1) { this._titleNode.textContent = nls.localize('oneComment', "1 comment"); } else { @@ -213,37 +160,151 @@ 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); } - item.appendChild(lineInfo); + itemMeta.appendChild(lineInfo); - // Feedback text - const text = $('span.agent-feedback-widget-text'); - text.textContent = feedback.text; + if (comment.source !== SessionEditorCommentSource.AgentFeedback) { + const typeBadge = $('span.agent-feedback-widget-item-type'); + typeBadge.textContent = this._getTypeLabel(comment); + itemMeta.appendChild(typeBadge); + } + + itemHeader.appendChild(itemMeta); + + const actionBarContainer = $('div.agent-feedback-widget-item-actions'); + const actionBar = this._eventStore.add(new ActionBar(actionBarContainer)); + if (comment.canConvertToAgentFeedback) { + actionBar.push(new Action( + 'agentFeedback.widget.convert', + nls.localize('convertComment', "Convert to Agent Feedback"), + ThemeIcon.asClassName(Codicon.check), + true, + () => this._convertToAgentFeedback(comment), + ), { icon: true, label: false }); + } + actionBar.push(new Action( + 'agentFeedback.widget.remove', + nls.localize('removeComment', "Remove"), + ThemeIcon.asClassName(Codicon.close), + true, + () => this._removeComment(comment), + ), { icon: true, label: false }); + itemHeader.appendChild(actionBarContainer); + item.appendChild(itemHeader); + + const text = $('div.agent-feedback-widget-text'); + const rendered = this._markdownRendererService.render(new MarkdownString(comment.text)); + this._eventStore.add(rendered); + text.appendChild(rendered.element); item.appendChild(text); - // Hover handlers for range highlighting + if (comment.suggestion?.edits.length) { + item.appendChild(this._renderSuggestion(comment)); + } + this._eventStore.add(addDisposableListener(item, 'mouseenter', () => { - this._highlightRange(feedback); + this._highlightRange(comment); })); this._eventStore.add(addDisposableListener(item, 'mouseleave', () => { this._rangeHighlightDecoration.clear(); })); + this._eventStore.add(addDisposableListener(item, 'click', e => { + if ((e.target as HTMLElement | null)?.closest('.action-bar')) { + return; + } + this.focusFeedback(comment.id); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, comment.id); + this._revealComment(comment); + })); + this._bodyNode.appendChild(item); } } + private _getTypeLabel(comment: ISessionEditorComment): string { + if (comment.source === SessionEditorCommentSource.PRReview) { + return nls.localize('prReviewComment', "PR Review"); + } + + if (comment.source === SessionEditorCommentSource.CodeReview) { + return comment.suggestion + ? nls.localize('reviewSuggestion', "Review Suggestion") + : nls.localize('reviewComment', "Review"); + } + + return comment.suggestion + ? nls.localize('feedbackSuggestion', "Feedback Suggestion") + : nls.localize('feedbackComment', "Feedback"); + } + + private _renderSuggestion(comment: ISessionEditorComment): HTMLElement { + const suggestionNode = $('div.agent-feedback-widget-suggestion'); + const title = $('div.agent-feedback-widget-suggestion-title'); + title.textContent = nls.localize('suggestedChange', "Suggested Change"); + suggestionNode.appendChild(title); + + for (const edit of comment.suggestion?.edits ?? []) { + const editNode = $('div.agent-feedback-widget-suggestion-edit'); + const rangeLabel = $('div.agent-feedback-widget-suggestion-range'); + if (edit.range.startLineNumber === edit.range.endLineNumber) { + rangeLabel.textContent = nls.localize('suggestionLineNumber', "Line {0}", edit.range.startLineNumber); + } else { + rangeLabel.textContent = nls.localize('suggestionLineRange', "Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber); + } + editNode.appendChild(rangeLabel); + + const newText = $('pre.agent-feedback-widget-suggestion-text'); + newText.textContent = edit.newText; + editNode.appendChild(newText); + suggestionNode.appendChild(editNode); + } + + return suggestionNode; + } + + private _removeComment(comment: ISessionEditorComment): void { + if (comment.source === SessionEditorCommentSource.PRReview) { + this._codeReviewService.resolvePRReviewThread(this._sessionResource!, comment.sourceId); + return; + } + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + return; + } + + this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId); + } + + private _convertToAgentFeedback(comment: ISessionEditorComment): void { + if (!comment.canConvertToAgentFeedback) { + return; + } + + const feedback = this._agentFeedbackService.addFeedback(this._sessionResource, comment.resourceUri, comment.range, comment.text, comment.suggestion); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + } + } + /** * Expand the widget body. */ @@ -277,7 +338,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 +361,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 +394,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 +412,17 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const scrollTop = this._editor.getScrollTop(); const widgetWidth = getTotalWidth(this._domNode) || 280; + const widgetHeight = this._domNode.offsetHeight || 0; + + // Compute content-relative top and clamp to keep the widget within the editor content area + const contentRelativeTop = this._editor.getTopForLineNumber(startLineNumber) - lineHeight; + 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 +435,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 +472,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); + } } /** @@ -424,25 +501,21 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @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 +523,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 { @@ -463,10 +548,13 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); } - 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 +563,105 @@ 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); + 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._agentSessionsService.getSession(this._sessionResource)?.changes; + if (!(changes instanceof Array)) { + 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..ea4414455e5db 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,7 @@ interface IFeedbackCommentElement { readonly id: string; readonly text: string; readonly resourceUri: URI; - readonly range: IRange; + readonly codeSelection?: string; } type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement; @@ -78,7 +79,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 +134,10 @@ class FeedbackFileRenderer implements ITreeRenderer; element: IFeedbackCommentElement | undefined; } @@ -139,8 +146,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 +188,53 @@ 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); + } + + return { + content: markdown, + style: HoverStyle.Pointer, + position: { + hoverPosition: HoverPosition.RIGHT, + }, + }; + } } // --- Hover --- @@ -202,16 +249,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 +301,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, @@ -313,6 +362,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 +375,7 @@ export class AgentFeedbackHover extends Disposable { id: item.id, text: item.text, resourceUri: item.resourceUri, - range: item.range, + codeSelection: item.codeSelection, }); } 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/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 03c6e09a1757c..c92a4ab67c448 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -11,9 +11,14 @@ 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 { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; // --- Types -------------------------------------------------------------------- @@ -23,6 +28,11 @@ export interface IAgentFeedback { readonly resourceUri: URI; readonly range: IRange; readonly sessionResource: URI; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface INavigableSessionComment { + readonly id: string; } export interface IAgentFeedbackChangeEvent { @@ -48,7 +58,7 @@ 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): IAgentFeedback; /** * Remove a single feedback item. @@ -70,20 +80,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): Promise; } // --- Implementation ----------------------------------------------------------- @@ -107,11 +131,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @IEditorService private readonly _editorService: IEditorService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, ) { super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -125,6 +152,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe resourceUri, range, sessionResource, + suggestion, }; // Insert at the correct sorted position. @@ -250,50 +278,131 @@ 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 sessionChange = this._getSessionChange(resourceUri, this._agentSessionsService.getSession(sessionResource)?.changes); + + if (sessionChange?.isDeletion && sessionChange.originalUri) { + await this._editorService.openEditor({ + resource: sessionChange.originalUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } else if (sessionChange?.originalUri) { + await this._editorService.openEditor({ + original: { resource: sessionChange.originalUri }, + modified: { resource: sessionChange.modifiedUri }, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } else { + await this._editorService.openEditor({ + resource: sessionChange?.modifiedUri ?? resourceUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } + + this.setNavigationAnchor(sessionResource, commentId); + } + + private _getSessionChange(resourceUri: URI, changes: readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[] | { + readonly files: number; + readonly insertions: number; + readonly deletions: number; + } | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined { + if (!(changes instanceof Array)) { + return undefined; + } + + const matchingChange = changes.find(change => this._changeContainsResource(change, resourceUri)); + if (!matchingChange) { + return undefined; + } + + if (isIChatSessionFileChange2(matchingChange)) { + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri ?? matchingChange.uri, + isDeletion: matchingChange.modifiedUri === undefined, + }; + } + + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri, + isDeletion: false, + }; + } + + private _changeContainsResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; } getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next); + } + + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key); - if (!feedbackItems?.length) { + if (!items.length) { this._navigationAnchorBySession.delete(key); return undefined; } const anchorId = this._navigationAnchorBySession.get(key); - let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1; if (anchorIndex < 0 && !next) { anchorIndex = 0; } const nextIndex = next - ? (anchorIndex + 1) % feedbackItems.length - : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + ? (anchorIndex + 1) % items.length + : (anchorIndex - 1 + items.length) % items.length; - const feedback = feedbackItems[nextIndex]; - this._navigationAnchorBySession.set(key, feedback.id); + const item = items[nextIndex]; + this.setNavigationAnchor(sessionResource, item.id); + return item; + } + + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void { + const key = sessionResource.toString(); + if (itemId) { + this._navigationAnchorBySession.set(key, itemId); + } else { + this._navigationAnchorBySession.delete(key); + } this._onDidChangeNavigation.fire(sessionResource); - return feedback; } - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key) ?? []; const anchorId = this._navigationAnchorBySession.get(key); - const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; - return { activeIdx, totalCount: feedbackItems.length }; + const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: items.length }; } clearFeedback(sessionResource: URI): void { @@ -304,4 +413,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): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion); + + // Wait for the attachment contribution to update the chat widget's attachment model + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (widget) { + const attachmentId = 'agentFeedback:' + sessionResource.toString(); + const hasAttachment = () => widget.attachmentModel.attachments.some(a => a.id === attachmentId); + + if (!hasAttachment()) { + await Event.toPromise( + Event.filter(widget.attachmentModel.onDidChange, () => hasAttachment()) + ); + } + } else { + this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString()); + await new Promise(resolve => setTimeout(resolve, 100)); + } + + try { + await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + } catch (err) { + this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err); + } + } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index f8d62a4fe28e7..b467ff7f7aa8d 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -7,10 +7,13 @@ position: absolute; z-index: 10000; background-color: var(--vscode-panel-background); - border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + border: 1px solid var(--vscode-agentFeedbackInputWidget-border, var(--vscode-input-border, var(--vscode-widget-border))); + box-shadow: var(--vscode-shadow-lg); border-radius: 8px; padding: 4px; + display: flex; + flex-direction: row; + align-items: flex-end; } .agent-feedback-input-widget textarea { @@ -18,7 +21,7 @@ border: none; color: var(--vscode-input-foreground); border-radius: 4px; - padding: 0; + padding: 0 0 0 6px; outline: none; min-width: 150px; max-width: 400px; @@ -28,6 +31,7 @@ word-wrap: break-word; box-sizing: border-box; display: block; + flex: 1; } .agent-feedback-input-widget textarea:focus { @@ -46,3 +50,15 @@ overflow: hidden; white-space: pre; } + +.agent-feedback-input-widget .agent-feedback-input-actions { + display: flex; + align-items: center; + margin-left: 2px; + flex-shrink: 0; +} + +.agent-feedback-input-widget .agent-feedback-input-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css index 1acdbe228ce56..766e481b9eb51 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css @@ -8,13 +8,13 @@ color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); border-radius: 6px; - border: 1px solid var(--vscode-contrastBorder); + border: 1px solid var(--vscode-editorHoverWidget-border); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 3ca674d2cf444..938da413b02e9 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; @@ -113,24 +113,6 @@ } /* Dismiss button */ -.agent-feedback-widget-dismiss { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 4px; - cursor: pointer; - color: var(--vscode-foreground); - opacity: 0.7; - transition: opacity 0.1s; -} - -.agent-feedback-widget-dismiss:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Body - collapsible */ .agent-feedback-widget-body { transition: max-height 0.2s ease-in-out, padding 0.2s ease-in-out; @@ -152,6 +134,7 @@ border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); cursor: pointer; position: relative; + gap: 6px; } .agent-feedback-widget-item:last-child { @@ -167,12 +150,70 @@ color: var(--vscode-list-activeSelectionForeground); } +.agent-feedback-widget-item-codeReview { + box-shadow: inset 2px 0 0 var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview { + box-shadow: inset 2px 0 0 var(--vscode-editorInfo-foreground); +} + +.agent-feedback-widget-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.agent-feedback-widget-item-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.agent-feedback-widget-item-actions { + margin-left: auto; + flex: 0 0 auto; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.agent-feedback-widget-item:hover .agent-feedback-widget-item-actions { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.agent-feedback-widget-item-type { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.2px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 22%, transparent); + color: var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 22%, transparent); + color: var(--vscode-editorInfo-foreground); +} + /* Line info */ .agent-feedback-widget-line-info { font-size: 10px; font-weight: 600; color: var(--vscode-descriptionForeground); - margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } @@ -183,6 +224,62 @@ word-wrap: break-word; } +.agent-feedback-widget-text .rendered-markdown p { + margin: 0; +} + +.agent-feedback-widget-text .rendered-markdown code { + font-family: var(--monaco-monospace-font); + font-size: 11px; + padding: 1px 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); +} + +.agent-feedback-widget-suggestion { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 12%, transparent); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 10%, transparent); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 10%, transparent); +} + +.agent-feedback-widget-suggestion-title, +.agent-feedback-widget-suggestion-range { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-suggestion-edit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-feedback-widget-suggestion-text { + margin: 0; + padding: 6px 8px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; + font-family: monospace; + font-size: 11px; + line-height: 1.45; + background: color-mix(in srgb, var(--vscode-editor-background) 65%, transparent); +} + /* Gutter decoration for range indicator on hover */ .agent-feedback-widget-range-glyph { margin-left: 8px; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css deleted file mode 100644 index 6f503b0143fbb..0000000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-editor .agent-feedback-line-decoration, -.monaco-editor .agent-feedback-add-hint { - border-radius: 3px; - display: flex !important; - align-items: center; - justify-content: center; - background-color: var(--vscode-editorHoverWidget-background); - cursor: pointer; - border: 1px solid var(--vscode-editorHoverWidget-border); - box-sizing: border-box; -} - -.monaco-editor .agent-feedback-line-decoration:hover, -.monaco-editor .agent-feedback-add-hint:hover { - background-color: var(--vscode-editorHoverWidget-border); -} - -.monaco-editor .agent-feedback-add-hint { - opacity: 0.7; -} - -.monaco-editor .agent-feedback-add-hint:hover { - opacity: 1; -} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts new file mode 100644 index 0000000000000..ef756423d42d2 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedback } from './agentFeedbackService.js'; +import { CodeReviewStateKind, ICodeReviewComment, ICodeReviewState, ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; + +export const enum SessionEditorCommentSource { + AgentFeedback = 'agentFeedback', + CodeReview = 'codeReview', + PRReview = 'prReview', +} + +export interface ISessionEditorComment { + readonly id: string; + readonly sourceId: string; + readonly source: SessionEditorCommentSource; + readonly sessionResource: URI; + readonly resourceUri: URI; + readonly range: IRange; + readonly text: string; + readonly suggestion?: ICodeReviewSuggestion; + readonly severity?: string; + readonly canConvertToAgentFeedback: boolean; +} + +export function getCodeReviewComments(reviewState: ICodeReviewState): readonly ICodeReviewComment[] { + return reviewState.kind === CodeReviewStateKind.Result ? reviewState.comments : []; +} + +export function getPRReviewComments(prReviewState: IPRReviewState | undefined): readonly IPRReviewComment[] { + return prReviewState?.kind === PRReviewStateKind.Loaded ? prReviewState.comments : []; +} + +export function getSessionEditorComments( + sessionResource: URI, + agentFeedbackItems: readonly IAgentFeedback[], + reviewState: ICodeReviewState, + prReviewState?: IPRReviewState, +): readonly ISessionEditorComment[] { + const comments: ISessionEditorComment[] = []; + + for (const item of agentFeedbackItems) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: item.resourceUri, + range: item.range, + text: item.text, + suggestion: item.suggestion, + canConvertToAgentFeedback: false, + }); + } + + for (const item of getCodeReviewComments(reviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.CodeReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.CodeReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + suggestion: item.suggestion, + severity: item.severity, + canConvertToAgentFeedback: true, + }); + } + + for (const item of getPRReviewComments(prReviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.PRReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.PRReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + canConvertToAgentFeedback: true, + }); + } + + comments.sort(compareSessionEditorComments); + return comments; +} + +export function compareSessionEditorComments(a: ISessionEditorComment, b: ISessionEditorComment): number { + return a.resourceUri.toString().localeCompare(b.resourceUri.toString()) + || Range.compareRangesUsingStarts(Range.lift(a.range), Range.lift(b.range)) + || a.source.localeCompare(b.source) + || a.sourceId.localeCompare(b.sourceId); +} + +export function groupNearbySessionEditorComments(items: readonly ISessionEditorComment[], lineThreshold: number = 5): ISessionEditorComment[][] { + if (items.length === 0) { + return []; + } + + const sorted = [...items].sort(compareSessionEditorComments); + const groups: ISessionEditorComment[][] = []; + let currentGroup: ISessionEditorComment[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const firstItem = currentGroup[0]; + const currentItem = sorted[i]; + + const sameResource = currentItem.resourceUri.toString() === firstItem.resourceUri.toString(); + const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; + + if (sameResource && verticalSpan <= lineThreshold) { + currentGroup.push(currentItem); + } else { + groups.push(currentGroup); + currentGroup = [currentItem]; + } + } + + groups.push(currentGroup); + return groups; +} + +export function getResourceEditorComments(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const resource = resourceUri.toString(); + return comments.filter(comment => comment.resourceUri.toString() === resource); +} + +export function toSessionEditorCommentId(source: SessionEditorCommentSource, sourceId: string): string { + return `${source}:${sourceId}`; +} + +export function hasAgentFeedbackComments(comments: readonly ISessionEditorComment[]): boolean { + return comments.some(comment => comment.source === SessionEditorCommentSource.AgentFeedback); +} diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts new file mode 100644 index 0000000000000..2314f52bc38b5 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { toAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AgentFeedbackOverlayWidget } from '../../browser/agentFeedbackEditorOverlay.js'; +import { clearAllFeedbackActionId, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from '../../browser/agentFeedbackEditorActions.js'; + +interface INavigationBearings { + readonly activeIdx: number; + readonly totalCount: number; +} + +interface IFixtureOptions { + readonly navigationBearings: INavigationBearings; + readonly hasAgentFeedbackActions?: boolean; +} + +class FixtureMenuService implements IMenuService { + constructor(private readonly _hasAgentFeedbackActions: boolean) { + } + + declare readonly _serviceBrand: undefined; + + createMenu(_id: MenuId): IMenu { + const navigateActions = [ + toAction({ id: navigationBearingFakeActionId, label: 'Navigation Status', run: () => { } }), + toAction({ id: navigatePreviousFeedbackActionId, label: 'Previous', class: 'codicon codicon-arrow-up', run: () => { } }), + toAction({ id: navigateNextFeedbackActionId, label: 'Next', class: 'codicon codicon-arrow-down', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[]; + + const submitActions = this._hasAgentFeedbackActions + ? [ + toAction({ id: submitFeedbackActionId, label: 'Submit', class: 'codicon codicon-send', run: () => { } }), + toAction({ id: clearAllFeedbackActionId, label: 'Clear', class: 'codicon codicon-clear-all', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[] + : []; + + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => submitActions.length > 0 + ? [ + ['navigate', navigateActions], + ['a_submit', submitActions], + ] + : [ + ['navigate', navigateActions], + ], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.classList.add('monaco-workbench'); + context.container.style.width = '420px'; + context.container.style.height = '64px'; + context.container.style.padding = '12px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IMenuService, new FixtureMenuService(options.hasAgentFeedbackActions ?? true)); + registerWorkbenchServices(reg); + }, + }); + + const widget = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackOverlayWidget)); + widget.show(options.navigationBearings); + context.container.appendChild(widget.getDomNode()); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + ZeroOfZero: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 0 }, + hasAgentFeedbackActions: false, + }), + }), + + SingleFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 1 }, + }), + }), + + FirstOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 3 }, + }), + }), + + ReviewOnlyTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 2 }, + hasAgentFeedbackActions: false, + }), + }), + + MiddleOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 1, totalCount: 3 }, + }), + }), + + MixedFourComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 4 }, + hasAgentFeedbackActions: true, + }), + }), + + LastOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 3 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts new file mode 100644 index 0000000000000..3beeee9243db4 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -0,0 +1,413 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Color } from '../../../../../base/common/color.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { CodeReviewStateKind, ICodeReviewService, ICodeReviewState, ICodeReviewSuggestion, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; +import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', + '', + 'function gamma() {', + '\tconst done = true;', + '\treturn done;', + '}', +].join('\n'); + +interface IFixtureOptions { + readonly expanded?: boolean; + readonly focusedCommentId?: string; + readonly hidden?: boolean; + readonly commentItems: readonly ISessionEditorComment[]; +} + +function createRange(startLineNumber: number, endLineNumber: number = startLineNumber): IRange { + return { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; +} + +function createFeedbackComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + return { + id: `agentFeedback:${id}`, + sourceId: id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + text, + suggestion, + canConvertToAgentFeedback: false, + }; +} + +function createReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + const range: IRange = { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; + + return { + id: `codeReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.CodeReview, + text, + resourceUri: fileResource, + range, + sessionResource, + suggestion, + severity: 'warning', + canConvertToAgentFeedback: true, + }; +} + +function createPRReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber): ISessionEditorComment { + return { + id: `prReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.PRReview, + text, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + sessionResource, + canConvertToAgentFeedback: true, + }; +} + +function createMockAgentFeedbackService(): IAgentFeedbackService { + return new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override addFeedback(): IAgentFeedback { + throw new Error('Not implemented for fixture'); + } + + override removeFeedback(): void { } + + override getFeedback(): readonly IAgentFeedback[] { + return []; + } + + override getMostRecentSessionForResource(): URI | undefined { + return undefined; + } + + override async revealFeedback(): Promise { } + + override getNextFeedback(): IAgentFeedback | undefined { + return undefined; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: 0 }; + } + + override getNextNavigableItem() { + return undefined; + } + + override setNavigationAnchor(): void { } + + override clearFeedback(): void { } + + override async addFeedbackAndSubmit(): Promise { } + }(); +} + +function createMockCodeReviewService(): ICodeReviewService { + return new class extends mock() { + private readonly _state = observableValue('fixture.reviewState', { kind: CodeReviewStateKind.Idle }); + + override getReviewState() { + return this._state; + } + + override hasReview(): boolean { + return false; + } + + override requestReview(): void { } + + override removeComment(): void { } + + override dismissReview(): void { } + + private readonly _prState = observableValue('fixture.prReviewState', { kind: PRReviewStateKind.None }); + + override getPRReviewState() { + return this._prState; + } + + override async resolvePRReviewThread(): Promise { } + }(); +} + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + + const colorMap = [ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#4fc1ff'), + Color.fromHex('#c586c0'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + Color.fromHex('#f44747'), + ]; + + TokenizationRegistry.setColorMap(colorMap); +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const agentFeedbackService = createMockAgentFeedbackService(); + const codeReviewService = createMockCodeReviewService(); + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + + const editorOptions: ICodeEditorWidgetOptions = { + contributions: [], + }; + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + editorOptions + )); + + editor.setModel(model); + + const widget = scopedDisposables.add(instantiationService.createInstance( + AgentFeedbackEditorWidget, + editor, + options.commentItems, + sessionResource, + )); + + widget.layout(options.commentItems[0].range.startLineNumber); + + if (options.expanded) { + widget.expand(); + } + + if (options.focusedCommentId) { + widget.focusFeedback(options.focusedCommentId); + } + + if (options.hidden) { + const domNode = widget.getDomNode(); + domNode.style.transition = 'none'; + domNode.style.animation = 'none'; + widget.toggle(false); + } +} + +const singleFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), +]; + +const groupedFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createFeedbackComment('f-2', 'This return statement can be simplified.', 3), + createFeedbackComment('f-3', 'Consider documenting why this branch is needed.', 6, 8), +]; + +const reviewOnly = [ + createReviewComment('r-1', 'Handle the null case before returning here.', 7), + createReviewComment('r-2', 'This branch needs a stronger explanation.', 8), +]; + +const mixedComments = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createReviewComment('r-1', 'This should be extracted into a helper.', 3), + createFeedbackComment('f-2', 'Consider renaming this for readability.', 4), +]; + +const reviewSuggestion: ICodeReviewSuggestion = { + edits: [ + { range: createRange(8), oldText: '\tconst third = second + 1;', newText: '\tconst third = second + computeOffset();' }, + ], +}; + +const suggestionMix = [ + createReviewComment('r-3', 'Prefer using the helper so the intent is explicit.', 8, 8, reviewSuggestion), + createFeedbackComment('f-3', 'Keep the helper name aligned with the domain concept.', 9), +]; + +const prReviewOnly = [ + createPRReviewComment('pr-1', 'This variable should be renamed to match our naming conventions.', 2), + createPRReviewComment('pr-2', 'Please add error handling for the edge case when second is zero.', 7, 8), +]; + +const allSourcesMixed = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createPRReviewComment('pr-1', 'Our style guide says to use descriptive names here.', 3), + createReviewComment('r-1', 'This should be extracted into a helper.', 6), + createPRReviewComment('pr-2', 'This logic duplicates what we have in utils.ts — consider reusing.', 8, 9), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + CollapsedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + }), + }), + + ExpandedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + expanded: true, + }), + }), + + CollapsedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + }), + }), + + ExpandedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + }), + }), + + ExpandedFocusedFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + focusedCommentId: 'agentFeedback:f-2', + }), + }), + + ExpandedReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: reviewOnly, + expanded: true, + }), + }), + + ExpandedMixedComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + }), + }), + + ExpandedFocusedReviewComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + focusedCommentId: 'codeReview:r-1', + }), + }), + + ExpandedReviewSuggestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: suggestionMix, + expanded: true, + }), + }), + + ExpandedPRReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: prReviewOnly, + expanded: true, + }), + }), + + ExpandedAllSourcesMixed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + }), + }), + + ExpandedFocusedPRReview: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + focusedCommentId: 'prReview:pr-2', + }), + }), + + HiddenWidget: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + hidden: true, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts new file mode 100644 index 0000000000000..f7bb4f0aa5b30 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.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 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 { CodeReviewStateKind, ICodeReviewState, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; +import { getResourceEditorComments, getSessionEditorComments, groupNearbySessionEditorComments, hasAgentFeedbackComments, SessionEditorCommentSource } from '../../browser/sessionEditorComments.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', + comments, + }; + } + + 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..2bcd3717a81b6 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 { IEditorService, MODAL_GROUP } 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(); } @@ -187,7 +213,7 @@ export class AICustomizationOverviewView extends ViewPane { private async openSection(sectionId: AICustomizationManagementSection): Promise { const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await this.editorService.openEditor(input, { pinned: true }); + const editor = await this.editorService.openEditor(input, { pinned: true }, MODAL_GROUP); // Deep-link to the section if (editor instanceof AICustomizationManagementEditor) { diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts index a7edc620be229..d090de545769e 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -14,6 +14,9 @@ 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'; //#region Utilities @@ -79,7 +82,66 @@ registerAction2(class extends Action2 { } }); +// 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: URIContext): 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: URIContext): 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 +155,16 @@ 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, +}); + //#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index d2e6fcf1933d6..4b92d3f59a820 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,12 +28,16 @@ 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'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -77,7 +82,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 +96,22 @@ interface IAICustomizationFileItem { readonly uri: URI; readonly name: string; readonly description?: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; } -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 +125,7 @@ class AICustomizationTreeDelegate implements IListVirtualDelegate { +class AICustomizationCategoryRenderer implements ITreeRenderer { readonly templateId = 'category'; renderTemplate(container: HTMLElement): ICategoryTemplateData { @@ -145,7 +165,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 +193,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; @@ -209,9 +243,45 @@ 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; } - disposeTemplate(_templateData: IFileTemplateData): void { } + disposeElement(_node: ITreeNode, _index: number, templateData: IFileTemplateData): void { + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IFileTemplateData): void { + templateData.templateDisposables.dispose(); + templateData.elementDisposables.dispose(); + } } /** @@ -219,7 +289,7 @@ class AICustomizationFileRenderer implements ITreeRenderer; + files?: Map; } /** @@ -248,6 +318,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 +421,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 +434,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 +451,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); @@ -375,6 +462,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 +472,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,7 +511,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private async getFilesForStorageAndType(storage: AICustomizationPromptsStorage, promptType: PromptsType): Promise { const cached = this.cache.get(promptType); // For skills, use the cached skills data @@ -537,7 +633,7 @@ export class AICustomizationViewPane extends ViewPane { [ new AICustomizationCategoryRenderer(), new AICustomizationGroupRenderer(), - new AICustomizationFileRenderer(), + new AICustomizationFileRenderer(this.menuService, this.contextKeyService, this.instantiationService), ], this.dataSource, { @@ -546,7 +642,7 @@ 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') { @@ -570,12 +666,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 }, MODAL_GROUP); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(e.element.section); + } } })); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css index 0756725fc2b39..3e3d9be3b356a 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); 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..1c02efa68e675 --- /dev/null +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { URI } from '../../../../base/common/uri.js'; + +const hasWorktreeAndRepositoryContextKey = new RawContextKey('agentSessionHasWorktreeAndRepository', false, { + type: 'boolean', + description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.") +}); + +class ApplyChangesToParentRepoContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.applyChangesToParentRepo'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const worktreeAndRepoKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService); + + this._register(autorun(reader => { + const activeSession = sessionManagementService.activeSession.read(reader); + const hasWorktreeAndRepo = !!activeSession?.worktree && !!activeSession?.repository; + 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.getActiveSession(); + if (!activeSession?.worktree || !activeSession?.repository) { + return; + } + + const worktreeRoot = activeSession.worktree; + const repoRoot = activeSession.repository; + + const openFolderAction = toAction({ + id: 'applyChangesToParentRepo.openFolder', + label: localize('openInVSCode', "Open in VS Code"), + run: () => { + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : 'vscode-insiders'; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); + params.set('session', activeSession.resource.toString()); + + openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: repoRoot.path, + query: params.toString(), + }), { openExternal: true }); + } + }); + + try { + // Get the worktree branch name. Since the worktree and parent repo + // share the same git object store, the parent can directly reference + // this branch for a merge. + const worktreeBranch = await commandService.executeCommand( + '_git.revParseAbbrevRef', + worktreeRoot.fsPath + ); + + if (!worktreeBranch) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('applyChangesNoBranch', "Could not determine worktree branch name."), + }); + return; + } + + // Merge the worktree branch into the parent repo. + // This is idempotent: if already merged, git says "Already up to date." + // If new commits exist, they're brought in. Handles partial applies naturally. + const result = await commandService.executeCommand('_git.mergeBranch', repoRoot.fsPath, worktreeBranch); + if (!result) { + logService.warn('[ApplyChangesToParentRepo] No result from merge command'); + } else { + notificationService.notify({ + severity: Severity.Info, + message: typeof result === 'string' && result.startsWith('Already up to date') + ? localize('alreadyUpToDate', 'Parent repository is up to date with worktree.') + : localize('applyChangesSuccess', 'Applied changes to parent repository.'), + actions: { primary: [openFolderAction] } + }); + } + } catch (err) { + logService.error('[ApplyChangesToParentRepo] Failed to apply changes', err); + notificationService.notify({ + severity: Severity.Warning, + message: localize('applyChangesConflict', "Failed to apply changes to parent repo. The parent repo may have diverged — resolve conflicts manually."), + actions: { primary: [openFolderAction] } + }); + } + } +} + +registerAction2(ApplyChangesToParentRepoAction); +registerWorkbenchContribution2(ApplyChangesToParentRepoContribution.ID, ApplyChangesToParentRepoContribution, WorkbenchPhase.AfterRestored); + +// Register the apply submenu in the session changes toolbar +MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, { + submenu: MenuId.ChatEditingSessionApplySubmenu, + title: localize2('applyActions', 'Apply Actions'), + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), +}); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts similarity index 85% rename from src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts rename to src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 3e69bba8dfa4f..9da044d818d4b 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -8,8 +8,11 @@ import { localize2 } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import './changesViewActions.js'; +import { ToggleChangesViewContribution } from './toggleChangesView.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -38,3 +41,5 @@ viewsRegistry.registerViews([{ order: 1, windowVisibility: WindowVisibility.Sessions }], changesViewContainer); + +registerWorkbenchContribution2(ToggleChangesViewContribution.ID, ToggleChangesViewContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts similarity index 64% rename from src/vs/sessions/contrib/changesView/browser/changesView.ts rename to src/vs/sessions/contrib/changes/browser/changesView.ts index 973e8e2ba1b4b..d7997d39bbd03 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -13,15 +13,15 @@ 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 { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -44,18 +44,25 @@ import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/la import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, 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'; +import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; +import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { CIStatusWidget } from './ciStatusWidget.js'; const $ = dom.$; @@ -63,6 +70,7 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; +const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -73,6 +81,19 @@ export const enum ChangesViewMode { const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); +// --- Versions Mode + +const enum ChangesVersionMode { + AllChanges = 'allChanges', + LastTurn = 'lastTurn', + Uncommitted = 'uncommitted' +} + +const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); +const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); +const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); +const hasUncommittedChangesContextKey = new RawContextKey('sessions.hasUncommittedChanges', false); + // --- List Item type ChangeType = 'added' | 'modified' | 'deleted'; @@ -86,6 +107,7 @@ interface IChangesFileItem { readonly changeType: ChangeType; readonly linesAdded: number; readonly linesRemoved: number; + readonly reviewCommentCount: number; } interface IChangesFolderItem { @@ -94,11 +116,6 @@ interface IChangesFolderItem { readonly name: string; } -interface IActiveSession { - readonly resource: URI; - readonly sessionType: string; -} - type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { @@ -124,17 +141,33 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement= 3) { + uriBasePrefix = '/' + parts.slice(0, 3).join('/'); + displayDirPath = '/' + parts.slice(3).join('/'); + } else { + uriBasePrefix = '/' + parts.join('/'); + displayDirPath = '/'; + } + } + + const segments = displayDirPath.split('/').filter(Boolean); let current = root; - let currentPath = ''; + let currentFullPath = uriBasePrefix; for (const segment of segments) { - currentPath += '/' + segment; + currentFullPath += '/' + segment; if (!current.children.has(segment)) { current.children.set(segment, { name: segment, - uri: item.uri.with({ path: currentPath }), + uri: item.uri.with({ path: currentFullPath }), children: new Map(), files: [] }); @@ -185,6 +218,7 @@ export class ChangesViewPane extends ViewPane { private actionsContainer: HTMLElement | undefined; private tree: WorkbenchCompressibleObjectTree | undefined; + private ciStatusWidget: CIStatusWidget | undefined; private readonly renderDisposables = this._register(new DisposableStore()); @@ -206,10 +240,24 @@ export class ChangesViewPane extends ViewPane { this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); } + // Version mode (all changes, last turn, uncommitted) + private readonly versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + private readonly versionModeContextKey: IContextKey; + + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + this.versionModeContextKey.set(mode); + } + // Track the active session used by this view - private readonly activeSession: IObservableWithChange; + private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryObs: IObservableWithChange; get activeSessionHasChanges(): IObservable { return this.activeSessionHasChangesObs; @@ -236,6 +284,9 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, + @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitService private readonly gitService: IGitService, + @IGitHubService private readonly gitHubService: IGitHubService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -246,8 +297,12 @@ export class ChangesViewPane extends ViewPane { this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); this.viewModeContextKey.set(initialMode); + // Version mode + this.versionModeContextKey = changesVersionModeContextKey.bindTo(contextKeyService); + this.versionModeContextKey.set(ChangesVersionMode.AllChanges); + // Track active session from sessions management service - this.activeSession = derivedOpts({ + this.activeSession = derivedOpts({ equalsFn: (a, b) => isEqual(a?.resource, b?.resource), }, reader => { const activeSession = this.sessionManagementService.activeSession.read(reader); @@ -255,33 +310,65 @@ export class ChangesViewPane extends ViewPane { return undefined; } - return { - resource: activeSession.resource, - sessionType: getChatSessionType(activeSession.resource), - }; + return activeSession; }).recomputeInitiallyAndOnChange(this._store); + // Track active session repository changes + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionWorktree = this.activeSession.read(reader)?.worktree; + if (!activeSessionWorktree) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(activeSessionWorktree)).resolvedValue; + }); + + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + this.activeSessionRepositoryChangesObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + if (!repository) { + return undefined; + } + + const state = repository.state.read(reader); + const headCommit = state?.HEAD?.commit; + return (state?.workingTreeChanges ?? []).map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + const fileUri = change.modifiedUri ?? change.uri; + return { + type: 'file', + uri: fileUri, + originalUri: isDeletion || !headCommit ? change.originalUri + : fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: headCommit }) }), + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + reviewCommentCount: 0, + linesAdded: 0, + linesRemoved: 0, + } satisfies IChangesFileItem; + }); + }); + 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); + viewSessionTypeKey.set(activeSession?.providerType ?? ''); })); } @@ -311,10 +398,8 @@ export class ChangesViewPane extends ViewPane { return 0; } - const isBackgroundSession = activeSession.sessionType === AgentSessionProviders.Background; - let editingSessionCount = 0; - if (!isBackgroundSession) { + if (activeSession.providerType !== AgentSessionProviders.Background) { 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; @@ -371,6 +456,9 @@ export class ChangesViewPane extends ViewPane { // List container this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + // CI Status widget beneath the card + this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.bodyContainer)); + this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { this.onVisible(); @@ -406,7 +494,7 @@ export class ChangesViewPane extends ViewPane { 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) { + if (activeSession?.providerType === AgentSessionProviders.Background) { return []; } @@ -432,6 +520,7 @@ export class ChangesViewPane extends ViewPane { changeType: isDeletion ? 'deleted' : 'modified', linesAdded, linesRemoved, + reviewCommentCount: 0, }); } @@ -457,37 +546,139 @@ export class ChangesViewPane extends ViewPane { return model?.changes instanceof Array ? model.changes : Iterable.empty(); }); + const reviewCommentCountByFileObs = derived(reader => { + const sessionResource = activeSessionResource.read(reader); + const sessionChanges = [...sessionFileChangesObs.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 (sessionChanges.length === 0) { + return result; + } + + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + 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; + }); + // Convert session file changes to list items (cloud/background sessions) - const sessionFilesObs = derived(reader => - [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const sessionFilesObs = derived(reader => { + const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + + return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; + const uri = isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri; return { type: 'file', - uri: isIChatSessionFileChange2(entry) - ? entry.modifiedUri ?? entry.uri - : entry.modifiedUri, + uri, originalUri: entry.originalUri, state: ModifiedFileEntryState.Accepted, isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: entry.insertions, linesRemoved: entry.deletions, + reviewCommentCount: reviewCommentCountByFile.get(uri.fsPath) ?? 0, }; - }) - ); + }); + }); + + // Create observable for last turn changes using diffBetweenWithStats + // Reactively computes the diff between HEAD^ and HEAD. Memoize the diff observable so + // that we only recompute it when the HEAD commit id actually changes. + const headCommitObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + return repository?.state.read(reader)?.HEAD?.commit; + }); + + const lastTurnChangesObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader); + const headCommit = headCommitObs.read(reader); + if (!repository || !headCommit) { + return constObservable(undefined); + } + + return new ObservablePromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit)).resolvedValue; + }); // Combine both entry sources for display const combinedEntriesObs = derived(reader => { + const headCommit = headCommitObs.read(reader); + const versionMode = this.versionModeObs.read(reader); const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; + const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + + let sourceEntries: IChangesFileItem[]; + if (versionMode === ChangesVersionMode.Uncommitted) { + sourceEntries = repositoryFiles; + } else if (versionMode === ChangesVersionMode.LastTurn) { + const diffChanges = lastTurnDiffChanges ?? []; + const parentRef = headCommit ? `${headCommit}^` : ''; + sourceEntries = diffChanges.map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + const fileUri = change.modifiedUri ?? change.uri; + const originalUri = isAddition ? change.originalUri + : headCommit ? fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: parentRef }) }) + : change.originalUri; + return { + type: 'file', + uri: fileUri, + originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + linesAdded: change.insertions, + linesRemoved: change.deletions, + reviewCommentCount: 0, + } satisfies IChangesFileItem; + }); + } else { + sourceEntries = [...editEntries, ...sessionFiles, ...repositoryFiles]; + } + + const resources = new Set(); + const entries: IChangesFileItem[] = []; + for (const item of sourceEntries) { + if (!resources.has(item.uri.fsPath)) { + resources.add(item.uri.fsPath); + entries.push(item); + } + } + return entries.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); }); // Calculate stats from combined entries const topLevelStats = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -498,7 +689,7 @@ export class ChangesViewPane extends ViewPane { } const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); return { files, added, removed, isSessionMenu }; }); @@ -507,19 +698,18 @@ export class ChangesViewPane extends ViewPane { 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]))); + const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.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, ''); + const chatSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); this.renderDisposables.add(autorun(reader => { const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.sessionType ?? ''); + chatSessionTypeKey.set(activeSession?.providerType ?? ''); })); // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, scopedContextKeyService, r => { + this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, this.scopedContextKeyService, r => { const session = activeEditingSessionObs.read(r); if (!session) { return false; @@ -528,7 +718,7 @@ export class ChangesViewPane extends ViewPane { return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); })); - this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, scopedContextKeyService, r => { + this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, this.scopedContextKeyService, r => { const session = activeEditingSessionObs.read(r); if (!session) { return false; @@ -537,20 +727,79 @@ export class ChangesViewPane extends ViewPane { return entries.length > 0; })); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => { - const { files } = topLevelStats.read(r); + const hasAgentSessionChangesObs = derived(reader => { + const { files } = topLevelStats.read(reader); return files > 0; - })); + }); + + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => hasAgentSessionChangesObs.read(r))); + + const hasUncommittedChangesObs = derived(reader => { + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader); + return (repositoryFiles?.length ?? 0) > 0; + }); + + this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => hasUncommittedChangesObs.read(r))); + + const isMergeBaseBranchProtectedObs = derived(reader => { + const activeSession = this.activeSession.read(reader); + return activeSession?.worktreeBaseBranchProtected === true; + }); + + this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, r => isMergeBaseBranchProtectedObs.read(r))); + + const hasOpenPullRequestObs = derived(reader => { + const sessionResource = activeSessionResource.read(reader); + if (!sessionResource) { + return false; + } + + sessionsChangedSignal.read(reader); + + const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; + return !!metadata?.pullRequestUrl; + }); + + this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, r => hasOpenPullRequestObs.read(r))); this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); const sessionResource = activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); // Re-evaluate when session metadata changes (e.g. pullRequestUrl) + const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + + // Read code review state to update the button label dynamically + let reviewCommentCount: number | undefined; + let codeReviewLoading = false; + if (sessionResource) { + const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader); + const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + const sessionChanges = this.agentSessionsService.getSession(sessionResource)?.changes; + if (sessionChanges instanceof Array && sessionChanges.length > 0) { + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) { + codeReviewLoading = true; + } else { + const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion ? reviewState.comments.length : 0; + const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount; + if (totalReviewCommentCount > 0) { + reviewCommentCount = totalReviewCommentCount; + } + } + } else if (prReviewCommentCount > 0) { + reviewCommentCount = prReviewCommentCount; + } + } + reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + menuId, { telemetrySource: 'changesView', + disableWhileRunning: isSessionMenu, menuOptions: isSessionMenu && sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, @@ -562,8 +811,26 @@ export class ChangesViewPane extends ViewPane { ); 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' }; + if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { + if (codeReviewLoading) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' }; + } + if (reviewCommentCount !== undefined) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewCommentCount), customClass: 'code-review-comments' }; + } + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'chatEditing.synchronizeChanges') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge') { + return { showIcon: true, showLabel: true, isSecondary: false }; } return undefined; } @@ -582,6 +849,11 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); + // Update badge when file count changes + this.renderDisposables.add(autorun(reader => { + this.updateBadge(topLevelStats.read(reader).files); + })); + // Update summary text (line counts only, file count is shown in badge) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); @@ -642,11 +914,9 @@ export class ChangesViewPane extends ViewPane { }, compressionEnabled: true, twistieAdditionalCssClass: (e: unknown) => { - if (this.viewMode === ChangesViewMode.List) { - return 'force-no-twistie'; - } - // In tree mode, hide twistie for file items (they are never collapsible) - return isChangesFileItem(e as ChangesTreeElement) ? 'force-no-twistie' : undefined; + return this.viewMode === ChangesViewMode.List + ? 'force-no-twistie' + : undefined; }, } ); @@ -656,6 +926,9 @@ export class ChangesViewPane extends ViewPane { if (this.tree) { const tree = this.tree; + // Re-layout when collapse state changes so the card height adjusts + this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutTree())); + const openFileItem = (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean) => { const { uri: modifiedFileUri, originalUri, isDeletion } = item; const currentIndex = items.indexOf(item); @@ -706,6 +979,33 @@ export class ChangesViewPane extends ViewPane { })); } + // Bind CI status widget to active session's PR CI model + if (this.ciStatusWidget) { + const activeSessionResourceObs = derived(this, reader => this.sessionManagementService.activeSession.read(reader)?.resource); + const ciModelObs = derived(this, reader => { + const session = this.sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = this.sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + // Use the PR's headRef from the PR model to get CI checks + const prModel = this.gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + // Trigger a refresh if PR data isn't loaded yet + prModel.refresh(); + return undefined; + } + const ciModel = this.gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + ciModel.refresh(); + return ciModel; + }); + this.renderDisposables.add(this.ciStatusWidget.bind(ciModelObs, activeSessionResourceObs)); + } + // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); @@ -753,8 +1053,10 @@ export class ChangesViewPane extends ViewPane { 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 ciWidgetHeight = this.ciStatusWidget?.element.offsetHeight ?? 0; + const ciWidgetMargin = ciWidgetHeight > 0 ? 8 : 0; // margin-top on CI widget - const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; + const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder + ciWidgetHeight + ciWidgetMargin; const availableHeight = Math.max(0, bodyHeight - usedHeight); // Limit height to the content so the tree doesn't exceed its items @@ -824,6 +1126,7 @@ interface IChangesTreeTemplate { readonly templateDisposables: DisposableStore; readonly toolbar: MenuWorkbenchToolBar | undefined; readonly contextKeyService: IContextKeyService | undefined; + readonly reviewCommentsBadge: HTMLElement; readonly decorationBadge: HTMLElement; readonly addedSpan: HTMLElement; readonly removedSpan: HTMLElement; @@ -846,6 +1149,9 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { @@ -898,6 +1204,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${data.reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + // Update decoration badge (A/M/D) const badge = templateData.decorationBadge; badge.className = 'changes-decoration-badge'; @@ -961,6 +1280,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer { registerAction2(SetChangesListViewModeAction); registerAction2(SetChangesTreeViewModeAction); + +// --- Versions Submenu + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: MenuId.ChatEditingSessionChangesVersionsSubmenu, + title: localize2('versionsActions', 'Versions'), + icon: Codicon.versions, + group: 'navigation', + order: 9, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', CHANGES_VIEW_ID), IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), +}); + +class AllChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsAllChanges', + title: localize2('chatEditing.versionsAllChanges', 'All Changes'), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.AllChanges), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 1, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.AllChanges); + } +} +registerAction2(AllChangesAction); + +class LastTurnChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsLastTurnChanges', + title: localize2('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.LastTurn), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 2, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.LastTurn); + } +} +registerAction2(LastTurnChangesAction); + +class UncommittedChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsUncommittedChanges', + title: localize2('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.Uncommitted), + precondition: hasUncommittedChangesContextKey, + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '2_uncommitted', + order: 1, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.Uncommitted); + } +} +registerAction2(UncommittedChangesAction); 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..9ca4eeb503dcf --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.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 { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableFromEvent } from '../../../../base/common/observable.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 { hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; + +import { activeSessionHasChangesContextKey } from '../common/changes.js'; + +const openChangesViewActionOptions: IAction2Options = { + id: 'workbench.action.agentSessions.openChangesView', + title: localize2('openChangesView', "Changes"), + icon: Codicon.diffMultiple, + f1: false, +}; + +class OpenChangesViewAction extends Action2 { + + static readonly ID = openChangesViewActionOptions.id; + + constructor() { + super(openChangesViewActionOptions); + } + + async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + await viewsService.openView(CHANGES_VIEW_ID, true); + } +} + +registerAction2(OpenChangesViewAction); + +class ChangesViewActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.changesViewActions'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, + ) { + super(); + + // Bind context key: true when the active session has changes + const sessionsChanged = observableFromEvent(this, agentSessionsService.model.onDidChangeSessions, () => { }); + this._register(bindContextKey(activeSessionHasChangesContextKey, contextKeyService, reader => { + sessionManagementService.activeSession.read(reader); + sessionsChanged.read(reader); + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return false; + } + const agentSession = agentSessionsService.getSession(activeSession.resource); + return !!agentSession?.changes && hasValidDiff(agentSession.changes); + })); + } +} + +registerWorkbenchContribution2(ChangesViewActionsContribution.ID, ChangesViewActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts new file mode 100644 index 0000000000000..78023d8bff411 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -0,0 +1,519 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/ciStatusWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { spinningLoading } from '../../../../platform/theme/common/iconRegistry.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; + +const $ = dom.$; + +const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +interface ICICheckListItem { + readonly check: IGitHubCICheck; + readonly group: CICheckGroup; +} + +interface ICICheckCounts { + readonly running: number; + readonly pending: number; + readonly failed: number; + readonly successful: number; +} + +class CICheckListDelegate implements IListVirtualDelegate { + static readonly ITEM_HEIGHT = 24; + + getHeight(_element: ICICheckListItem): number { + return CICheckListDelegate.ITEM_HEIGHT; + } + + getTemplateId(_element: ICICheckListItem): string { + return CICheckListRenderer.TEMPLATE_ID; + } +} + +interface ICICheckTemplateData { + readonly row: HTMLElement; + readonly label: IResourceLabel; + readonly actionBar: ActionBar; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +class CICheckListRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'ciCheck'; + readonly templateId = CICheckListRenderer.TEMPLATE_ID; + + constructor( + private readonly _labels: ResourceLabels, + private readonly _openerService: IOpenerService, + ) { } + + renderTemplate(container: HTMLElement): ICICheckTemplateData { + const templateDisposables = new DisposableStore(); + const row = dom.append(container, $('.ci-status-widget-check')); + + const labelContainer = dom.append(row, $('.ci-status-widget-check-label')); + const label = templateDisposables.add(this._labels.create(labelContainer, { supportIcons: true })); + + const actionBarContainer = dom.append(row, $('.ci-status-widget-check-actions')); + const actionBar = templateDisposables.add(new ActionBar(actionBarContainer)); + + return { + row, + label, + actionBar, + templateDisposables, + elementDisposables: templateDisposables.add(new DisposableStore()), + }; + } + + renderElement(element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + + templateData.row.className = `ci-status-widget-check ${getCheckStatusClass(element.check)}`; + + const title = localize('ci.checkTitle', "{0}: {1}", element.check.name, getCheckStateLabel(element.check)); + templateData.label.setResource({ + name: element.check.name, + resource: URI.from({ scheme: 'github-check', path: `/${element.check.id}/${element.check.name}` }), + }, { + icon: getCheckIcon(element.check), + title, + }); + + const actions: Action[] = []; + + if (element.check.detailsUrl) { + actions.push(templateData.elementDisposables.add(new Action( + 'ci.openOnGitHub', + localize('ci.openOnGitHub', "Open on GitHub"), + ThemeIcon.asClassName(Codicon.linkExternal), + true, + async () => { + await this._openerService.open(URI.parse(element.check.detailsUrl!)); + }, + ))); + } + + templateData.actionBar.push(actions, { icon: true, label: false }); + } + + disposeElement(_element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + } + + disposeTemplate(templateData: ICICheckTemplateData): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * A collapsible widget that shows the CI status of a PR. + * Rendered beneath the changes tree in the changes view. + */ +export class CIStatusWidget extends Disposable { + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabel: IResourceLabel; + private readonly _headerActionBarContainer: HTMLElement; + private readonly _headerActionBar: ActionBar; + private readonly _twistieNode: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _list: WorkbenchList; + private readonly _labels: ResourceLabels; + private readonly _headerActionDisposables = this._register(new DisposableStore()); + + private _collapsed = true; + private _model: GitHubPullRequestCIModel | undefined; + private _sessionResource: URI | undefined; + + get element(): HTMLElement { + return this._domNode; + } + + constructor( + container: HTMLElement, + @IOpenerService private readonly _openerService: IOpenerService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._domNode = dom.append(container, $('.ci-status-widget')); + this._domNode.style.display = 'none'; + + // Header (always visible) + this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title')); + this._titleLabel = this._register(this._labels.create(this._titleNode, { supportIcons: true })); + this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions')); + this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer)); + this._headerActionBarContainer.style.display = 'none'; + this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + })); + this._twistieNode = dom.append(this._headerNode, $('.ci-status-widget-twistie')); + this._updateTwistie(); + + this._register(dom.addDisposableListener(this._headerNode, 'click', () => this._toggle())); + + // Body (collapsible list of checks) + this._bodyNode = dom.append(this._domNode, $('.ci-status-widget-body')); + this._bodyNode.style.display = 'none'; + + const listContainer = $('.ci-status-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList, + 'CIStatusWidget', + listContainer, + new CICheckListDelegate(), + [new CICheckListRenderer(this._labels, this._openerService)], + { + multipleSelectionSupport: false, + openOnSingleClick: false, + accessibilityProvider: { + getWidgetAriaLabel: () => localize('ci.checksListAriaLabel', "Checks"), + getAriaLabel: item => localize('ci.checkAriaLabel', "{0}, {1}", item.check.name, getCheckStateLabel(item.check)), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: item => item.check.name, + }, + }, + )); + this._bodyNode.appendChild(this._list.getHTMLElement()); + } + + /** + * Bind to a CI model. When `ciModel` is undefined, the widget hides. + * Returns a disposable that stops observation. + */ + bind(ciModel: IObservable, sessionResource: IObservable): IDisposable { + return autorun(reader => { + const model = ciModel.read(reader); + this._sessionResource = sessionResource.read(reader); + this._model = model; + if (!model) { + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + return; + } + + const checks = model.checks.read(reader); + const overallStatus = model.overallStatus.read(reader); + + if (checks.length === 0) { + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + return; + } + + this._domNode.style.display = ''; + this._renderHeader(checks, overallStatus); + this._renderHeaderActions(getFailedChecks(checks)); + this._renderBody(sortChecks(checks)); + }); + } + + private _toggle(): void { + this._collapsed = !this._collapsed; + this._bodyNode.style.display = this._collapsed ? 'none' : ''; + this._updateTwistie(); + } + + private _updateTwistie(): void { + dom.clearNode(this._twistieNode); + this._twistieNode.appendChild(renderIcon(this._collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + } + + private _renderHeader(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): void { + const { icon, className } = getHeaderIconAndClass(checks, overallStatus); + this._titleNode.className = `ci-status-widget-title ${className}`; + + const summary = getChecksSummary(checks); + const title = localize('ci.headerTitle', "Checks: {0}", summary); + this._titleLabel.setResource({ + name: title, + resource: URI.from({ scheme: 'github-checks', path: '/summary' }), + }, { + icon: icon, + title, + }); + } + + private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void { + this._headerActionDisposables.clear(); + this._headerActionBar.clear(); + + if (failedChecks.length === 0) { + this._headerActionBarContainer.style.display = 'none'; + return; + } + + const fixChecksAction = this._headerActionDisposables.add(new Action( + 'ci.fixChecks', + localize('ci.fixChecks', "Fix Checks"), + ThemeIcon.asClassName(Codicon.sparkle), + true, + async () => { + await this._sendFixChecksPrompt(failedChecks); + }, + )); + + this._headerActionBar.push([fixChecksAction], { icon: true, label: false }); + this._headerActionBarContainer.style.display = 'flex'; + } + + private _renderBody(checks: readonly ICICheckListItem[]): void { + const height = checks.length * CICheckListDelegate.ITEM_HEIGHT; + this._list.getHTMLElement().style.height = `${height}px`; + this._list.layout(height); + 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 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; + } +} + +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 getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +function getChecksSummary(checks: readonly IGitHubCICheck[]): string { + const counts = getCheckCounts(checks); + const parts: string[] = []; + + if (counts.running > 0) { + parts.push(counts.running === 1 + ? localize('ci.oneRunning', "1 running") + : localize('ci.manyRunning', "{0} running", counts.running)); + } + + if (counts.pending > 0) { + parts.push(counts.pending === 1 + ? localize('ci.onePending', "1 pending") + : localize('ci.manyPending', "{0} pending", counts.pending)); + } + + if (counts.failed > 0) { + parts.push(counts.failed === 1 + ? localize('ci.oneFailed', "1 failed") + : localize('ci.manyFailed', "{0} failed", counts.failed)); + } + + if (counts.successful > 0) { + parts.push(counts.successful === 1 + ? localize('ci.oneSuccessful', "1 successful") + : localize('ci.manySuccessful', "{0} successful", counts.successful)); + } + + return parts.join(', '); +} + +function 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'); +} + +function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): { icon: ThemeIcon; className: string } { + const counts = getCheckCounts(checks); + if (counts.running > 0) { + return { icon: spinningLoading, className: 'ci-status-running' }; + } + + switch (overallStatus) { + case GitHubCIOverallStatus.Success: + return { icon: Codicon.passFilled, className: 'ci-status-success' }; + case GitHubCIOverallStatus.Failure: + return { icon: Codicon.error, className: 'ci-status-failure' }; + case GitHubCIOverallStatus.Pending: + return { icon: Codicon.circle, className: 'ci-status-pending' }; + default: + return { icon: Codicon.circleFilled, className: 'ci-status-neutral' }; + } +} + +function getCheckIcon(check: IGitHubCICheck): ThemeIcon { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return spinningLoading; + case GitHubCheckStatus.Queued: + return Codicon.circle; + 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; + } + } +} + +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'; + } +} + +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"); + } +} + +function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css similarity index 82% rename from src/vs/sessions/contrib/changesView/browser/media/changesView.css rename to src/vs/sessions/contrib/changes/browser/media/changesView.css index 1300b886cbcd8..1b18723383299 100644 --- a/src/vs/sessions/contrib/changesView/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -104,7 +104,6 @@ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button { height: 26px; padding: 4px 14px; - border-radius: 4px; font-size: 12px; line-height: 18px; } @@ -114,6 +113,29 @@ flex: 1; } +/* ButtonWithDropdown container grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown { + flex: 1; + display: flex; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button { + flex: 1; + box-sizing: border-box; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button-dropdown-separator { + flex: 0; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + flex: 0 0 auto; + padding: 4px; + width: auto; + min-width: 0; + border-radius: 0px 4px 4px 0px; +} + .changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { padding: 4px 8px; font-size: 16px !important; @@ -136,6 +158,10 @@ color: var(--vscode-button-secondaryForeground); } +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { + border-radius: 4px 0px 0px 4px; +} + .changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { background-color: var(--vscode-button-secondaryHoverBackground); color: var(--vscode-button-secondaryForeground); @@ -213,6 +239,19 @@ font-size: 11px; } +.changes-view-body .chat-editing-session-list .changes-review-comments-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + margin-right: 6px; + color: var(--vscode-descriptionForeground); +} + +.changes-view-body .chat-editing-session-list .changes-review-comments-badge .codicon { + font-size: 12px; +} + .changes-view-body .chat-editing-session-list .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); } @@ -235,3 +274,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/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css new file mode 100644 index 0000000000000..2acaa1fa18d76 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* CI Status Widget - beneath the changes tree */ +.ci-status-widget { + margin-top: 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + background-color: var(--vscode-editor-background); + overflow: hidden; + font-size: 12px; +} + +/* Header - always visible, clickable */ +.ci-status-widget-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + min-height: 22px; +} + +.ci-status-widget-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Title - single line, overflow ellipsis */ +.ci-status-widget-title { + flex: 1; + overflow: hidden; + color: var(--vscode-foreground); +} + +.ci-status-widget-title .monaco-icon-label { + width: 100%; +} + +.ci-status-widget-title .monaco-icon-label-container, +.ci-status-widget-title .monaco-icon-name-container { + display: block; + overflow: hidden; +} + +.ci-status-widget-title .label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ci-status-widget-header-actions { + flex: 0 0 auto; + display: none; + align-items: center; + margin-left: auto; +} + +.ci-status-widget-header-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-header-actions .action-item .action-label { + width: 16px; + height: 16px; +} + +/* Twistie icon on the right */ +.ci-status-widget-twistie { + flex: 0 0 auto; + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +/* Body - collapsible list */ +.ci-status-widget-body { + border-top: 1px solid var(--vscode-input-border, transparent); +} + +.ci-status-widget-list { + background-color: transparent; +} + +.ci-status-widget-list > .monaco-list, +.ci-status-widget-list > .monaco-list > .monaco-scrollable-element { + background-color: transparent; +} + +/* Individual check row */ +.ci-status-widget-check { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check { + background-color: var(--vscode-list-hoverBackground); +} + +.ci-status-widget-check-label { + display: flex; + flex: 1; + min-width: 0; + overflow: hidden; +} + + +.ci-status-widget-check-label .monaco-icon-label { + display: flex; + flex: 1; + min-width: 0; + width: 100%; +} + +.ci-status-widget-check-label .monaco-icon-label-container, +.ci-status-widget-check-label .monaco-icon-name-container { + display: block; + min-width: 0; + overflow: hidden; +} + +.ci-status-widget-check-label .label-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); +} + +.ci-status-widget-title.ci-status-success .monaco-icon-label::before, +.ci-status-widget-check.ci-status-success .monaco-icon-label::before { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.ci-status-widget-title.ci-status-failure .monaco-icon-label::before, +.ci-status-widget-check.ci-status-failure .monaco-icon-label::before { + color: var(--vscode-testing-iconFailed, #f14c4c); +} + +.ci-status-widget-title.ci-status-running .monaco-icon-label::before, +.ci-status-widget-check.ci-status-running .monaco-icon-label::before, +.ci-status-widget-title.ci-status-pending .monaco-icon-label::before, +.ci-status-widget-check.ci-status-pending .monaco-icon-label::before { + color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground)); +} + +.ci-status-widget-title.ci-status-neutral .monaco-icon-label::before, +.ci-status-widget-check.ci-status-neutral .monaco-icon-label::before { + color: var(--vscode-descriptionForeground); +} + +/* Actions - float to the right, visible on hover */ +.ci-status-widget-check-actions { + display: none; + flex: 0 0 auto; + flex-shrink: 0; + margin-left: auto; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check-actions, +.ci-status-widget-check:hover .ci-status-widget-check-actions { + display: flex; +} + +.ci-status-widget-check-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-check-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts b/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts similarity index 89% rename from src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts rename to src/vs/sessions/contrib/changes/browser/toggleChangesView.ts index eb36b915ad62d..abc40780aa638 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsAuxiliaryBarContribution.ts +++ b/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts @@ -14,16 +14,18 @@ import { IChatService } from '../../../../workbench/contrib/chat/common/chatServ 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'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; interface IPendingTurnState { readonly hadChangesBeforeSend: boolean; readonly submittedAt: number; } -export class SessionsAuxiliaryBarContribution extends Disposable { +export class ToggleChangesViewContribution extends Disposable { - static readonly ID = 'workbench.contrib.sessionsAuxiliaryBarContribution'; + static readonly ID = 'workbench.contrib.toggleChangesView'; private readonly pendingTurnStateByResource = new ResourceMap(); @@ -33,6 +35,7 @@ export class SessionsAuxiliaryBarContribution extends Disposable { @IChatEditingService private readonly chatEditingService: IChatEditingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatService private readonly chatService: IChatService, + @IViewsService private readonly viewsService: IViewsService, ) { super(); @@ -106,12 +109,10 @@ export class SessionsAuxiliaryBarContribution extends Disposable { } private syncAuxiliaryBarVisibility(hasChanges: boolean): void { - const shouldHideAuxiliaryBar = !hasChanges; - const isAuxiliaryBarVisible = this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); - if (shouldHideAuxiliaryBar === !isAuxiliaryBarVisible) { - return; + if (hasChanges) { + this.viewsService.openView(CHANGES_VIEW_ID, false); + } else { + this.layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); } - - this.layoutService.setPartHidden(shouldHideAuxiliaryBar, Parts.AUXILIARYBAR_PART); } } diff --git a/extensions/git/extension.webpack.config.js b/src/vs/sessions/contrib/changes/common/changes.ts similarity index 59% rename from extensions/git/extension.webpack.config.js rename to src/vs/sessions/contrib/changes/common/changes.ts index 34f801e2eca4e..23c69cd418217 100644 --- a/extensions/git/extension.webpack.config.js +++ b/src/vs/sessions/contrib/changes/common/changes.ts @@ -2,16 +2,8 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - ['askpass-main']: './src/askpass-main.ts', - ['git-editor-main']: './src/git-editor-main.ts' - } -}); +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -export const StripOutSourceMaps = ['dist/askpass-main.js']; +export const activeSessionHasChangesContextKey = new RawContextKey('activeSessionHasChanges', false, localize('activeSessionHasChanges', "Whether the active session has changes.")); diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 32d2236832b09..1f0fd2142adaf 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -3,39 +3,110 @@ * 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 { joinPath, 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, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.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 { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { localize } from '../../../../nls.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. * Delegates to ISessionsManagementService to provide the active session's * worktree/repository as the project root, and supports worktree commit. + * + * Customization files are always committed to the main repository so they + * persist across worktrees. When a worktree is active the file is also + * copied into the worktree and committed there so the running session + * picks it up immediately. */ export class SessionsAICustomizationWorkspaceService implements IAICustomizationWorkspaceService { declare readonly _serviceBrand: undefined; readonly activeProjectRoot: IObservable; + readonly hasOverrideProjectRoot: IObservable; + + /** + * Transient override for the project root. When set, `activeProjectRoot` + * returns this value instead of the session-derived root. + */ + private readonly _overrideRoot: ISettableObservable; + + /** + * CLI-accessible user directories for customization file filtering and creation. + */ + private readonly _cliUserRoots: readonly URI[]; + + /** + * Pre-built filter for types that should only show CLI-accessible user roots. + */ + private readonly _cliUserFilter: IStorageSourceFilter; constructor( @ISessionsManagementService private readonly sessionsService: ISessionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPromptsService private readonly promptsService: IPromptsService, + @IPathService pathService: IPathService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @IFileService private readonly fileService: IFileService, + @INotificationService private readonly notificationService: INotificationService, ) { + const userHome = pathService.userHome({ preferLocal: true }); + this._cliUserRoots = [ + joinPath(userHome, '.copilot'), + joinPath(userHome, '.claude'), + joinPath(userHome, '.agents'), + ]; + this._cliUserFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE], + includedUserFileRoots: this._cliUserRoots, + }; + + 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; }); + + this.hasOverrideProjectRoot = derived(reader => { + return this._overrideRoot.read(reader) !== undefined; + }); } getActiveProjectRoot(): URI | undefined { + const override = this._overrideRoot.get(); + if (override) { + return override; + } const session = this.sessionsService.getActiveSession(); return session?.worktree ?? session?.repository; } + setOverrideProjectRoot(root: URI): void { + this._overrideRoot.set(root, undefined); + } + + clearOverrideProjectRoot(): void { + this._overrideRoot.set(undefined, undefined); + } + readonly managementSections: readonly AICustomizationManagementSection[] = [ AICustomizationManagementSection.Agents, AICustomizationManagementSection.Skills, @@ -43,15 +114,172 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Models, + AICustomizationManagementSection.Plugins, ]; - readonly preferManualCreation = true; + private static readonly _hooksFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.plugin], + }; + + private static readonly _allUserRootsFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE], + }; + + getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { + if (type === PromptsType.hook) { + return SessionsAICustomizationWorkspaceService._hooksFilter; + } + if (type === PromptsType.prompt) { + // Prompts are shown from all user roots (including VS Code profile) + return SessionsAICustomizationWorkspaceService._allUserRootsFilter; + } + // Other types only show user files from CLI-accessible roots (~/.copilot, ~/.claude, ~/.agents) + return this._cliUserFilter; + } + + 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.getActiveSession(); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitFileToRepos(fileUri, session.repository, session.worktree); + } + } - async commitFiles(projectRoot: URI, fileUris: URI[]): Promise { + /** + * 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.getActiveSession(); - if (session) { - await this.sessionsService.commitWorktreeFiles(session, fileUris); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitDeletionToRepos(fileUri, session.repository, session.worktree); + } + } + + /** + * 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); + + // 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 +287,12 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization const creator = this.instantiationService.createInstance(CustomizationCreatorService); await creator.createWithAI(type); } + + async getFilteredPromptSlashCommands(token: CancellationToken): Promise { + const allCommands = await this.promptsService.getPromptSlashCommands(token); + return allCommands.filter(cmd => { + const filter = this.getStorageSourceFilter(cmd.promptPath.type); + return applyStorageSourceFilter([cmd.promptPath], filter).length > 0; + }); + } } diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index 7744e54a3dacf..0e4ff4ce967ce 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -12,8 +12,6 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ 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; @@ -31,7 +29,7 @@ interface IBranchItem { export class BranchPicker extends Disposable { private _selectedBranch: string | undefined; - private _newSession: INewSession | undefined; + private _preferredBranch: string | undefined; private _branches: string[] = []; private readonly _onDidChange = this._register(new Emitter()); @@ -48,19 +46,19 @@ export class BranchPicker extends Disposable { return this._selectedBranch; } + /** + * Sets a preferred branch to select when branches are loaded. + */ + setPreferredBranch(branch: string | undefined): void { + this._preferredBranch = branch; + } + 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. @@ -70,7 +68,7 @@ export class BranchPicker extends Disposable { this._selectedBranch = undefined; if (!repository) { - this._newSession?.setBranch(undefined); + this._onDidChange.fire(undefined); this._setLoading(false); this._updateTriggerLabel(); return; @@ -85,8 +83,11 @@ export class BranchPicker extends Disposable { .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) + // Select preferred branch (from draft), active branch, main, master, or the first branch + const preferred = this._preferredBranch; + this._preferredBranch = undefined; + const defaultBranch = (preferred ? this._branches.find(b => b === preferred) : undefined) + ?? 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]; @@ -177,7 +178,7 @@ export class BranchPicker extends Disposable { return this._branches.map(branch => ({ kind: ActionListItemKind.Action, label: branch, - group: { title: '', icon: this._selectedBranch === branch ? Codicon.check : Codicon.blank }, + group: { title: '', icon: Codicon.gitBranch }, item: { name: branch }, })); } @@ -185,7 +186,6 @@ export class BranchPicker extends Disposable { private _selectBranch(branch: string): void { if (this._selectedBranch !== branch) { this._selectedBranch = branch; - this._newSession?.setBranch(branch); this._onDidChange.fire(branch); this._updateTriggerLabel(); } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index b7879423464a0..5e49081cc2bf9 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -17,8 +17,7 @@ import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensi import { Registry } from '../../../../platform/registry/common/platform.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; +import { IsActiveSessionBackgroundProviderContext, ISessionsManagementService, IsNewChatSessionContext } from '../../sessions/browser/sessionsManagementService.js'; import { Menus } from '../../../browser/menus.js'; import { BranchChatSessionAction } from './branchChatSessionAction.js'; import { RunScriptContribution } from './runScriptAction.js'; @@ -37,6 +36,8 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; export class OpenSessionWorktreeInVSCodeAction extends Action2 { static readonly ID = 'chat.openSessionWorktreeInVSCode'; @@ -46,11 +47,12 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { id: OpenSessionWorktreeInVSCodeAction.ID, title: localize2('openInVSCode', 'Open in VS Code'), icon: Codicon.vscodeInsiders, + precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: IsAuxiliaryWindowContext.toNegated() + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); } @@ -65,7 +67,7 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { return; } - const folderUri = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud ? activeSession.worktree : undefined; + const folderUri = activeSession.providerType === AgentSessionProviders.Background ? activeSession?.worktree ?? activeSession?.repository : undefined; if (!folderUri) { return; @@ -137,11 +139,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({ 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 index 1080d93df7dce..26d7626d54698 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -7,19 +7,15 @@ 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 { basename, extUriBiasedIgnorePathCase, 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 { ICommandService } from '../../../../platform/commands/common/commands.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'; @@ -29,6 +25,7 @@ const FILTER_THRESHOLD = 10; interface IFolderItem { readonly uri: URI; readonly label: string; + readonly checked?: boolean; } /** @@ -44,8 +41,6 @@ export class FolderPicker extends Disposable { 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()); @@ -54,20 +49,11 @@ export class FolderPicker extends Disposable { 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, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -84,15 +70,6 @@ export class FolderPicker extends Disposable { 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 */ }); } /** @@ -135,7 +112,7 @@ export class FolderPicker extends Disposable { return; } - const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const currentFolderUri = this._selectedFolderUri; const items = this._buildItems(currentFolderUri); const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; @@ -145,6 +122,8 @@ export class FolderPicker extends Disposable { this.actionWidgetService.hide(); if (item.uri.scheme === 'command' && item.uri.path === 'browse') { this._browseForFolder(); + } else if (item.uri.scheme === 'command' && item.uri.path === 'clone') { + this._cloneRepository(); } else { this._selectFolder(item.uri); } @@ -152,6 +131,8 @@ export class FolderPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; + const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('folderPicker.filter', "Filter folders...") } : undefined; + this.actionWidgetService.show( 'folderPicker', false, @@ -164,12 +145,12 @@ export class FolderPicker extends Disposable { getAriaLabel: (item) => item.label ?? '', getWidgetAriaLabel: () => localize('folderPicker.ariaLabel', "Folder Picker"), }, - showFilter ? { showFilter: true, filterPlaceholder: localize('folderPicker.filter', "Filter folders...") } : undefined, + listOptions, ); } /** - * Programmatically set the selected folder. + * Programmatically set the selected folder (e.g. restoring draft state). */ setSelectedFolder(folderUri: URI): void { this._selectFolder(folderUri); @@ -188,7 +169,6 @@ export class FolderPicker extends Disposable { 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); } @@ -208,6 +188,17 @@ export class FolderPicker extends Disposable { } } + private async _cloneRepository(): Promise { + try { + const clonedPath: string | undefined = await this.commandService.executeCommand('git.clone', undefined, undefined, { postCloneAction: 'none' }); + if (clonedPath) { + this._selectFolder(URI.file(clonedPath)); + } + } catch { + // clone 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); @@ -215,45 +206,32 @@ export class FolderPicker extends Disposable { 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) + // Collect all folders (current + recently picked), deduplicated and sorted by name + const allFolders: { uri: URI; label: string }[] = []; if (currentFolderUri) { - items.push({ - kind: ActionListItemKind.Action, - label: basename(currentFolderUri), - group: { title: '', icon: Codicon.check }, - item: { uri: currentFolderUri, label: basename(currentFolderUri) }, - }); + seenUris.add(currentFolderUri.toString()); + allFolders.push({ 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(); + for (const folderUri of this._recentlyPickedFolders) { + const key = folderUri.toString(); if (seenUris.has(key)) { continue; } seenUris.add(key); - const label = folder.label || basename(folder.uri); + allFolders.push({ uri: folderUri, label: basename(folderUri) }); + } + allFolders.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); + for (const folder of allFolders) { + const isCurrent = currentFolderUri && isEqual(folder.uri, currentFolderUri); 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), - })], + label: folder.label, + group: { title: '', icon: Codicon.folder }, + item: { uri: folder.uri, label: folder.label, checked: isCurrent || false }, + ...(!isCurrent ? { onRemove: () => this._removeFolder(folder.uri) } : {}), }); } @@ -267,32 +245,36 @@ export class FolderPicker extends Disposable { items.push({ kind: ActionListItemKind.Action, label: localize('browseFolder', "Browse..."), - group: { title: '', icon: Codicon.folderOpened }, + group: { title: '', icon: Codicon.search }, item: { uri: URI.from({ scheme: 'command', path: 'browse' }), label: localize('browseFolder', "Browse...") }, }); + items.push({ + kind: ActionListItemKind.Action, + label: localize('cloneRepository', "Clone..."), + group: { title: '', icon: Codicon.repoClone }, + item: { uri: URI.from({ scheme: 'command', path: 'clone' }), label: localize('cloneRepository', "Clone...") }, + }); return items; } - private _removeFolder(folderUri: URI): void { - // Remove from recently picked folders + /** + * Removes a folder from the recently picked list and storage. + */ + removeFromRecents(folderUri: URI): void { 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(); + // If this was the last picked folder, clear it + if (this._selectedFolderUri && isEqual(this._selectedFolderUri, folderUri)) { + this._selectedFolderUri = undefined; + this.storageService.remove(STORAGE_KEY_LAST_FOLDER, StorageScope.PROFILE); + this._updateTriggerLabel(this._triggerElement); + } } - private _isCopilotWorktree(uri: URI): boolean { - const name = basename(uri); - return name.startsWith('copilot-worktree-'); + private _removeFolder(folderUri: URI): void { + 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); } private _updateTriggerLabel(trigger: HTMLElement | undefined): void { @@ -301,7 +283,7 @@ export class FolderPicker extends Disposable { } dom.clearNode(trigger); - const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const folderUri = this._selectedFolderUri; const label = folderUri ? basename(folderUri) : localize('pickFolder', "Pick Folder"); dom.append(trigger, renderIcon(Codicon.folder)); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 89375629adb1a..16e1ab73d4742 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,7 +12,7 @@ height: 100%; box-sizing: border-box; overflow: hidden; - padding-bottom: 10%; + padding: 0 12px 48px 12px; container-type: size; } @@ -35,7 +35,7 @@ width: 100%; max-width: 200px; aspect-ratio: 1/1; - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-dark.svg'); + background-image: url('./letterpress-sessions-dark.svg'); background-size: contain; background-position: center; background-repeat: no-repeat; @@ -43,16 +43,9 @@ margin-bottom: 20px; } -.vs .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-light.svg'); -} - +.vs .chat-full-welcome-letterpress, .hc-light .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcLight.svg'); -} - -.hc-black .chat-full-welcome-letterpress { - background-image: url('../../../../../workbench/browser/parts/editor/media/letterpress-hcDark.svg'); + background-image: url('./letterpress-sessions-light.svg'); } @container (max-height: 350px) { @@ -116,6 +109,7 @@ display: none; flex-direction: row; align-items: center; + gap: 4px; min-height: 28px; } @@ -361,3 +355,12 @@ .sessions-chat-picker-slot .action-label span + .chat-session-option-label { margin-left: 2px; } + +/* 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..b9e7bbe6a02d4 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -8,6 +8,7 @@ flex-direction: column; height: 100%; width: 100%; + position: relative; } /* Welcome container fills available space and centers content */ @@ -38,11 +39,9 @@ } /* Editor */ +/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ .sessions-chat-editor { padding: 0 6px 6px 6px; - height: 50px; - min-height: 36px; - max-height: 200px; flex-shrink: 1; } @@ -66,6 +65,12 @@ flex: 1; } +.sessions-chat-toolbar-pickers { + display: flex; + align-items: center; + gap: 4px; +} + /* Model picker - uses workbench ModelPickerActionItem */ .sessions-chat-model-picker { display: flex; @@ -110,6 +115,11 @@ color: var(--vscode-icon-foreground); background: transparent !important; border: none !important; + cursor: pointer; +} + +.sessions-chat-send-button .monaco-button.disabled { + cursor: default; } .sessions-chat-send-button .monaco-button:not(.disabled):hover { @@ -222,6 +232,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 +279,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-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-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 .codicon { + height: 12px; + font-size: 12px; + margin-right: 3px; } diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg deleted file mode 100644 index 81991ee80fa80..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg deleted file mode 100644 index 55db4d45e46fb..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg deleted file mode 100644 index e26c10e038aa0..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg deleted file mode 100644 index e26c10e038aa0..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg new file mode 100644 index 0000000000000..623629695fc17 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg new file mode 100644 index 0000000000000..29dfd5459d13c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/letterpress-sessions-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css new file mode 100644 index 0000000000000..0837bc7b8c0e8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/runScriptAction.css @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.run-script-action-widget { + display: flex; + flex-direction: column; + gap: 12px; + background-color: var(--vscode-quickInput-background); + padding: 8px 8px 12px; +} + +.run-script-action-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.run-script-action-label { + font-size: 12px; + font-weight: 600; +} + +.run-script-action-input .monaco-inputbox { + width: 100%; +} + +.run-script-action-option-row { + display: flex; + align-items: center; + min-height: 22px; + gap: 8px; +} + +.run-script-action-option-text { + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.run-script-action-section .monaco-custom-radio { + width: fit-content; + max-width: 100%; +} + +.run-script-action-hint { + font-size: 12px; + opacity: 0.8; +} + +.run-script-action-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} diff --git a/src/vs/sessions/contrib/chat/browser/modePicker.ts b/src/vs/sessions/contrib/chat/browser/modePicker.ts new file mode 100644 index 0000000000000..d7cc8df61b379 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/modePicker.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * 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 { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementCommands } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; + +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, + ) { + super(); + + this._register(this.chatModeService.onDidChangeChatModes(() => { + // Refresh the trigger label when available chat modes change + if (this._triggerElement) { + this._updateTriggerLabel(); + } + })); + } + + /** + * Sets the git repository. When the repository changes, resets the selected mode + * back to the default Agent mode. + */ + setRepository(repository: IGitRepository | undefined): 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; + } + + /** + * Shows or hides the picker. + */ + setVisible(visible: boolean): void { + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; + } + } + + private _getAvailableModes(): IChatMode[] { + const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(AgentSessionProviders.Background); + const effectiveTarget = customAgentTarget && customAgentTarget !== Target.Undefined ? customAgentTarget : Target.GitHubCopilot; + const modes = this.chatModeService.getModes(); + + // Always include the default Agent mode + const result: IChatMode[] = [ChatMode.Agent]; + + // Add custom modes matching the target and visible to users + for (const mode of modes.custom) { + const target = mode.target.get(); + if (target === effectiveTarget || target === Target.Undefined) { + const visibility = mode.visibility?.get(); + if (visibility && !visibility.userInvocable) { + continue; + } + result.push(mode); + } + } + + return result; + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const modes = this._getAvailableModes(); + + const items = this._buildItems(modes); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.kind === 'mode') { + this._selectMode(item.mode); + } else { + this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'localModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modePicker.ariaLabel', "Mode Picker"), + }, + ); + } + + private _buildItems(modes: IChatMode[]): IActionListItem[] { + const items: IActionListItem[] = []; + + // Default Agent mode + const agentMode = modes[0]; + items.push({ + kind: ActionListItemKind.Action, + label: agentMode.label.get(), + group: { title: '', icon: this._selectedMode.id === agentMode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode: agentMode }, + }); + + // Custom modes (with separator if any exist) + const customModes = modes.slice(1); + if (customModes.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + for (const mode of customModes) { + items.push({ + kind: ActionListItemKind.Action, + label: mode.label.get(), + group: { title: '', icon: this._selectedMode.id === mode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode }, + }); + } + } + + // Configure Custom Agents action + items.push({ kind: ActionListItemKind.Separator, label: '' }); + items.push({ + kind: ActionListItemKind.Action, + label: localize('configureCustomAgents', "Configure Custom Agents..."), + group: { title: '', icon: Codicon.blank }, + item: { kind: 'configure' }, + }); + + return items; + } + + private _selectMode(mode: IChatMode): void { + this._selectedMode = mode; + this._updateTriggerLabel(); + this._onDidChange.fire(mode); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + const icon = this._selectedMode.icon.get(); + if (icon) { + dom.append(this._triggerElement, renderIcon(icon)); + } + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = this._selectedMode.label.get(); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + const modes = this._getAvailableModes(); + this._slotElement?.classList.toggle('disabled', modes.length <= 1); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts new file mode 100644 index 0000000000000..1cdae827ae9b1 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.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 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 { IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { RemoteNewSession } from './newSession.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, + ) { + super(); + } + + /** + * Sets the remote session and loads the available models from it. + */ + 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; + } + + /** + * Shows or hides the picker. + */ + setVisible(visible: boolean): void { + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; + } + } + + private _loadModels(session: RemoteNewSession): void { + const modelOption = session.getModelOptionGroup(); + if (modelOption?.group.items.length) { + this._models = modelOption.group.items.map(item => ({ + id: item.id, + name: item.name, + description: item.description, + })); + + // Select the session's current value, or the default, or the first + if (!this._selectedModel || !this._models.some(m => m.id === this._selectedModel!.id)) { + const value = modelOption.value; + this._selectedModel = value + ? { id: value.id, name: value.name, description: value.description } + : this._models[0]; + } + } else { + this._models = []; + } + this._updateTriggerLabel(); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || this._models.length === 0) { + return; + } + + const items = this._buildItems(); + const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectModel(item); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'remoteModelPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modelPicker.ariaLabel', "Model Picker"), + }, + showFilter ? { showFilter: true, filterPlaceholder: localize('modelPicker.filter', "Filter models...") } : undefined, + ); + } + + private _buildItems(): IActionListItem[] { + return this._models.map(model => ({ + kind: ActionListItemKind.Action, + label: model.name, + group: { title: '', icon: this._selectedModel?.id === model.id ? Codicon.check : Codicon.blank }, + item: model, + })); + } + + private _selectModel(item: IModelItem): void { + this._selectedModel = item; + this._updateTriggerLabel(); + + if (this._session) { + this._session.setModelId(item.id); + this._session.setOptionValue('models', { id: item.id, name: item.name }); + } + this._onDidChange.fire({ id: item.id, name: item.name, description: item.description }); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const label = this._selectedModel?.name ?? localize('modelPicker.auto', "Auto"); + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + this._slotElement?.classList.toggle('disabled', this._models.length === 0); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 7219eaeafd80f..9acac072639ed 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ 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'; @@ -17,19 +18,25 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.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 +61,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 +80,12 @@ 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, ) { super(); + this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); } // --- Rendering --- @@ -81,6 +101,7 @@ export class NewChatContextAttachments extends Disposable { } this._renderDisposables.clear(); + this._resourceLabels.clear(); dom.clearNode(this._container); if (this._attachedContext.length === 0) { @@ -89,17 +110,30 @@ 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)); + const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; + 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 - const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { @@ -120,68 +154,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 +415,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 +459,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 +490,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..83c775b304b23 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.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 * 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 { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import Severity from '../../../../base/common/severity.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; + +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); + +interface IPermissionItem { + readonly level: ChatPermissionLevel; + readonly label: string; + readonly icon: ThemeIcon; + readonly checked: boolean; +} + +/** + * A permission picker for the new-session welcome view. + * Shows Default Approvals, Bypass Approvals, and Autopilot options. + */ +export class NewChatPermissionPicker extends Disposable { + + private readonly _onDidChangeLevel = this._register(new Emitter()); + readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; + + private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; + private _triggerElement: HTMLElement | undefined; + private _container: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + get permissionLevel(): ChatPermissionLevel { + return this._currentLevel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + ) { + super(); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._container = 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; + } + + setVisible(visible: boolean): void { + if (this._container) { + this._container.style.display = visible ? '' : 'none'; + } + } + + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + const isAutopilotEnabled = this.configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.shield }, + item: { + level: ChatPermissionLevel.Default, + label: localize('permissions.default', "Default Approvals"), + icon: Codicon.shield, + checked: this._currentLevel === ChatPermissionLevel.Default, + }, + label: localize('permissions.default', "Default Approvals"), + description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + disabled: false, + }, + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.warning }, + item: { + level: ChatPermissionLevel.AutoApprove, + label: localize('permissions.autoApprove', "Bypass Approvals"), + icon: Codicon.warning, + checked: this._currentLevel === ChatPermissionLevel.AutoApprove, + }, + label: localize('permissions.autoApprove', "Bypass Approvals"), + description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + disabled: policyRestricted, + }, + ]; + + if (isAutopilotEnabled) { + items.push({ + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.rocket }, + item: { + level: ChatPermissionLevel.Autopilot, + label: localize('permissions.autopilot', "Autopilot (Preview)"), + icon: Codicon.rocket, + checked: this._currentLevel === ChatPermissionLevel.Autopilot, + }, + label: localize('permissions.autopilot', "Autopilot (Preview)"), + description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + disabled: policyRestricted, + }); + } + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: async (item) => { + this.actionWidgetService.hide(); + await this._selectLevel(item.level); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'permissionPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"), + }, + ); + } + + 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)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 7fbfe0a318729..fc7078fa224c3 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -11,17 +11,19 @@ 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 { autorun, observableValue } 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, IContextKey, RawContextKey } 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,32 +36,55 @@ 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 * as aria from '../../../../base/browser/ui/aria/aria.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.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 { IChatSessionProviderOptionItem } 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 { 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 { IsolationMode, IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { INewSession } from './newSession.js'; +import { SyncIndicator } from './syncIndicator.js'; +import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { RepoPicker } from './repoPicker.js'; +import { CloudModelPicker } from './modelPicker.js'; +import { ModePicker } from './modePicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; - -const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; +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 { NewChatPermissionPicker } from './newChatPermissionPicker.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 extends IChatModelInputState { + target?: AgentSessionProviders; + isolationMode?: IsolationMode; + branch?: string; + folderUri?: string; + repo?: string; +} // #region --- Chat Welcome Widget --- @@ -79,15 +104,24 @@ 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 _targetPicker: SessionTargetPicker; private readonly _isolationModePicker: IsolationModePicker; private readonly _branchPicker: BranchPicker; + private readonly _syncIndicator: SyncIndicator; 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 _editorContainer!: HTMLElement; private readonly _currentLanguageModel = observableValue('currentLanguageModel', undefined); private readonly _modelPickerDisposable = this._register(new MutableDisposable()); @@ -109,81 +143,171 @@ class NewChatWidget extends Disposable { // Welcome part private _pickersContainer: HTMLElement | undefined; private _extensionPickersLeftContainer: HTMLElement | undefined; - private _extensionPickersRightContainer: HTMLElement | undefined; + private _toolbarPickersContainer: HTMLElement | undefined; + private _localModelPickerContainer: 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 _permissionPicker: NewChatPermissionPicker; + private readonly _repoPicker: RepoPicker; + private _repoPickerContainer: HTMLElement | undefined; + private readonly _cloudModelPicker: CloudModelPicker; + private readonly _modePicker: ModePicker; + private readonly _toolbarPickerWidgets = new Map(); + private readonly _toolbarPickerDisposables = 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: [], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: undefined, + selections: [], + contrib: {} + }; + + // 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, @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._permissionPicker = this._register(this.instantiationService.createInstance(NewChatPermissionPicker)); + this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker)); + this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); + this._modePicker = this._register(this.instantiationService.createInstance(ModePicker)); + this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, this._resolveDefaultTarget(options))); this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker)); this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); + this._syncIndicator = this._register(this.instantiationService.createInstance(SyncIndicator)); 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._updateIsolationPickerVisibility(); + this._permissionPicker.setVisible(isLocal); this._branchPicker.setVisible(isLocal); + this._syncIndicator.setVisible(isLocal); + this._updateDraftState(); this._focusEditor(); })); - this._register(this.contextKeyService.onDidChangeContext(e => { - if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { - this._renderExtensionPickers(true); - } - })); - this._register(this._branchPicker.onDidChangeLoading(loading => { this._branchLoading = loading; this._updateInputLoadingState(); })); - this._register(this._branchPicker.onDidChange(() => { + this._register(this._branchPicker.onDidChange((branch) => { + this._newSession.value?.setBranch(branch); + this._syncIndicator.setBranch(branch); + this._updateDraftState(); + this._focusEditor(); + })); + + this._register(this._folderPicker.onDidSelectFolder(async (folderUri) => { + const trusted = await this._requestFolderTrust(folderUri); + if (trusted) { + this._newSession.value?.setRepoUri(folderUri); + } + this._updateDraftState(); + this._focusEditor(); + })); + + this._register(this._isolationModePicker.onDidChange((mode) => { + this._newSession.value?.setIsolationMode(mode); + this._branchPicker.setVisible(mode === 'worktree'); + this._syncIndicator.setVisible(mode === 'worktree'); + this._updateDraftState(); this._focusEditor(); })); - this._register(this._folderPicker.onDidSelectFolder(() => { + // When mode changes, update the session + this._register(this._modePicker.onDidChange((mode) => { + this._newSession.value?.setMode(mode); this._focusEditor(); })); - this._register(this._isolationModePicker.onDidChange(() => { + this._register(this._repoPicker.onDidSelectRepo((repoId) => { + if (this._targetPicker.selectedTarget !== AgentSessionProviders.Background) { + this._newSession.value?.setRepoUri(this._getRepoUri(repoId)); + } + this._updateDraftState(); + })); + + // When language models change (e.g., extension activates), reinitialize if no model selected + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + this._initDefaultModel(); + })); + + // Update input state when attachments or model change + this._register(this._contextAttachments.onDidChangeContext(() => { + this._updateDraftState(); this._focusEditor(); })); + this._register(autorun(reader => { + this._currentLanguageModel.read(reader); + this._updateDraftState(); + })); + + // When isolation option config changes, update picker visibility + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('github.copilot.chat.cli.isolationOption.enabled')) { + this._updateIsolationPickerVisibility(); + } + })); + } + + private get _isIsolationOptionEnabled(): boolean { + return this.configurationService.getValue('github.copilot.chat.cli.isolationOption.enabled') !== false; + } + + private _updateIsolationPickerVisibility(): void { + const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background; + const enabled = this._isIsolationOptionEnabled; + if (!enabled) { + this._isolationModePicker.setPreferredIsolationMode('worktree'); + } + this._isolationModePicker.setVisible(isLocal); + this._isolationModePicker.setEnabled(enabled); } // --- Rendering --- render(container: HTMLElement): void { const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); + + // Overflow widget DOM node at the top level so the suggest widget + // is not clipped by any overflow:hidden ancestor. + const editorOverflowWidgetsDomNode = dom.append(container, dom.$('.sessions-chat-editor-overflow.monaco-editor')); + this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); + const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); // Watermark letterpress @@ -198,7 +322,7 @@ class NewChatWidget extends Disposable { // Input area inside the input slot const inputArea = dom.$('.sessions-chat-input-area'); - this._contextAttachments.registerDropTarget(inputArea); + this._contextAttachments.registerDropTarget(wrapper); this._contextAttachments.registerPasteHandler(inputArea); // Attachments row (pills only) inside input area, above editor @@ -206,21 +330,26 @@ 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); + this._permissionPicker.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); + this._syncIndicator.render(branchContainer); - // Set initial visibility based on default target + // Set initial visibility based on default target and isolation mode const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background; - this._isolationModePicker.setVisible(isLocal); - this._branchPicker.setVisible(isLocal); + this._updateIsolationPickerVisibility(); + this._permissionPicker.setVisible(isLocal); + const isWorktree = this._isolationModePicker.isolationMode === 'worktree'; + this._branchPicker.setVisible(isLocal && isWorktree); + this._syncIndicator.setVisible(isLocal && isWorktree); // Render target buttons & extension pickers this._renderOptionGroupPickers(); @@ -228,6 +357,9 @@ class NewChatWidget extends Disposable { // Initialize model picker this._initDefaultModel(); + // Restore draft input state from storage + this._restoreState(); + // Create initial session this._createNewSession(); @@ -242,7 +374,16 @@ class NewChatWidget extends Disposable { private async _createNewSession(): Promise { const target = this._targetPicker.selectedTarget; - const defaultRepoUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + let defaultRepoUri = this._folderPicker.selectedFolderUri; + + // For local targets, request workspace trust before creating the session + if (target === AgentSessionProviders.Background && defaultRepoUri) { + const trusted = await this._requestFolderTrust(defaultRepoUri); + if (!trusted) { + defaultRepoUri = undefined; + } + } + const resource = getResourceForNewChatSession({ type: target, position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, @@ -260,42 +401,58 @@ class NewChatWidget extends Disposable { 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); + // Wire pickers to the new session and disconnect inactive ones + const target = this._targetPicker.selectedTarget; + if (target === AgentSessionProviders.Background) { + session.setIsolationMode(this._isolationModePicker.isolationMode); + if (this._branchPicker.selectedBranch) { + session.setBranch(this._branchPicker.selectedBranch); + } + } else { + const selectedRepo = this._repoPicker.selectedRepo; + if (selectedRepo) { + session.setRepoUri(this._getRepoUri(selectedRepo)); + } + } - // Set the current model on the session + // Set the current model on the session (for local sessions) const currentModel = this._currentLanguageModel.get(); if (currentModel) { session.setModelId(currentModel.identifier); } + // Set the current mode on the session (for local sessions) + session.setMode(this._modePicker.selectedMode); + // 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) => { + const listeners = new DisposableStore(); + listeners.add(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(); } - }); + })); + if (session instanceof RemoteNewSession) { + this._renderRemoteSessionPickers(session, true); + listeners.add(session.onDidChangeOptionGroups(() => { + this._renderRemoteSessionPickers(session); + })); + } else { + this._renderLocalSessionPickers(); + } + + this._newSessionListener.value = listeners; this._updateSendButtonState(); } @@ -307,6 +464,9 @@ class NewChatWidget extends Disposable { this._updateInputLoadingState(); this._branchPicker.setRepository(undefined); this._isolationModePicker.setRepository(undefined); + this._updateIsolationPickerVisibility(); + this._syncIndicator.setRepository(undefined); + this._modePicker.setRepository(undefined); this.gitService.openRepository(folderUri).then(repository => { if (cts.token.isCancellationRequested) { @@ -315,7 +475,10 @@ class NewChatWidget extends Disposable { this._repositoryLoading = false; this._updateInputLoadingState(); this._isolationModePicker.setRepository(repository); + this._updateIsolationPickerVisibility(); this._branchPicker.setRepository(repository); + this._syncIndicator.setRepository(repository); + this._modePicker.setRepository(repository); }).catch(e => { if (cts.token.isCancellationRequested) { return; @@ -324,7 +487,10 @@ class NewChatWidget extends Disposable { this._repositoryLoading = false; this._updateInputLoadingState(); this._isolationModePicker.setRepository(undefined); + this._updateIsolationPickerVisibility(); this._branchPicker.setRepository(undefined); + this._syncIndicator.setRepository(undefined); + this._modePicker.setRepository(undefined); }); } @@ -348,8 +514,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 +538,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 +633,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()); @@ -421,33 +656,44 @@ class NewChatWidget extends Disposable { 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 this._folderPicker.selectedFolderUri; + } + + // For cloud targets, use the repo picker's selection + const selectedRepo = this._repoPicker.selectedRepo; + if (selectedRepo && selectedRepo.includes('/')) { + return this._getRepoUri(selectedRepo); } return undefined; } + private _getRepoUri(repoId: string): URI { + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${repoId}/HEAD`, + }); + } + private _createBottomToolbar(container: HTMLElement): void { const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); this._createAttachButton(toolbar); - const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); - this._createModelPicker(modelPickerContainer); + // Mode picker (before model pickers) + this._modePicker.render(toolbar); + this._modePicker.setVisible(false); + + // Local model picker (EnhancedModelPickerActionItem) + this._localModelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); + this._createLocalModelPicker(this._localModelPickerContainer); + + // Remote model picker (action list dropdown) + this._cloudModelPicker.render(toolbar); + this._cloudModelPicker.setVisible(false); + + this._toolbarPickersContainer = dom.append(toolbar, dom.$('.sessions-chat-toolbar-pickers')); dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); @@ -467,21 +713,23 @@ class NewChatWidget extends Disposable { // --- Model picker --- - private _createModelPicker(container: HTMLElement): void { + private _createLocalModelPicker(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, + useGroupedModelPicker: () => true, + showManageModelsAction: () => false, + showUnavailableFeatured: () => false, + showFeatured: () => true, }; const pickerOptions: IChatInputPickerOptions = { - onlyShowIconsForDefaultActions: observableValue('onlyShowIcons', false), + hideChevrons: observableValue('hideChevrons', false), hoverPosition: { hoverPosition: HoverPosition.ABOVE }, }; @@ -495,27 +743,11 @@ class NewChatWidget extends Disposable { } 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); - } - } - })); + const draft = this._getDraftState(); + const lastModelId = draft?.selectedModel?.identifier ?? this._history.values.at(-1)?.selectedModel?.identifier; + const defaultModel = (lastModelId ? models.find(m => m.identifier === lastModelId) : undefined) ?? models[0]; + this._currentLanguageModel.set(defaultModel, undefined); } private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { @@ -534,7 +766,7 @@ class NewChatWidget extends Disposable { return; } - this._clearExtensionPickers(); + this._clearAllPickers(); dom.clearNode(this._pickersContainer); const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); @@ -547,178 +779,137 @@ class NewChatWidget extends Disposable { // 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')); + this._extensionPickersLeftContainer.style.display = 'none'; - // Folder picker (rendered once, shown/hidden based on target) + // Repo picker for cloud (rendered once, shown/hidden based on target) + this._repoPickerContainer = dom.append(rightHalf, dom.$('.sessions-chat-extension-pickers-right')); + this._repoPickerContainer.style.display = 'none'; + this._repoPicker.render(this._repoPickerContainer); + + // Folder picker for local (rendered once, shown/hidden based on target) this._folderPickerContainer = this._folderPicker.render(rightHalf); this._folderPickerContainer.style.display = 'none'; - - this._renderExtensionPickers(); } - // --- Welcome: Extension option pickers (Cloud target only) --- + // --- Local session pickers --- - private _renderExtensionPickers(force?: boolean): void { - if (!this._extensionPickersRightContainer) { - return; + private _renderLocalSessionPickers(): void { + this._clearAllPickers(); + if (this._folderPickerContainer) { + this._folderPickerContainer.style.display = ''; } - - const activeSessionType = this._targetPicker.selectedTarget; - - // 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'; - } - return; + if (this._extensionPickersLeftContainer) { + this._extensionPickersLeftContainer.style.display = 'block'; } - - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); - if (!optionGroups || optionGroups.length === 0) { - this._clearExtensionPickers(); - return; + // Show local model and mode pickers, hide remote + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; } + this._modePicker.setVisible(true); + this._cloudModelPicker.setVisible(false); + } - 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); - } - } + // --- Remote session pickers --- - if (visibleGroups.length === 0) { - this._clearExtensionPickers(); + private _renderRemoteSessionPickers(session: RemoteNewSession, force?: boolean): void { + if (!this._repoPickerContainer) { return; } - if (!force && this._pickerWidgets.size === visibleGroups.length) { - const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); - if (allMatch) { - return; - } + // Hide local-only pickers + if (this._folderPickerContainer) { + this._folderPickerContainer.style.display = 'none'; } - this._clearExtensionPickers(); + // Show remote model picker, hide local pickers + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = 'none'; + } + this._modePicker.setVisible(false); + this._cloudModelPicker.setSession(session); + this._cloudModelPicker.setVisible(true); + // Show repo picker and separator if (this._extensionPickersLeftContainer) { this._extensionPickersLeftContainer.style.display = 'block'; } + this._repoPickerContainer.style.display = ''; - for (const optionGroup of visibleGroups) { - const initialItem = this._getDefaultOptionForGroup(optionGroup); - const initialState = { group: optionGroup, item: initialItem }; - - if (initialItem) { - this._updateOptionContextKey(optionGroup.id, initialItem.id); - } + // Render toolbar pickers (other groups) + this._renderToolbarPickers(session, force); + } - 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, - }; + private _renderToolbarPickers(session: RemoteNewSession, force?: boolean): void { + if (!this._toolbarPickersContainer) { + return; + } - const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); - const widget = this.instantiationService.createInstance( - optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, - action, initialState, itemDelegate - ); + const toolbarOptions = session.getOtherOptionGroups(); - this._pickerWidgetDisposables.add(widget); - this._pickerWidgets.set(optionGroup.id, widget); + // Filter by item availability (when-clause filtering is done by the session) + const visibleGroups = toolbarOptions.filter(option => { + const group = option.group; + return group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + }); - const slot = dom.append(this._extensionPickersRightContainer!, dom.$('.sessions-chat-picker-slot')); - widget.render(slot); + if (visibleGroups.length === 0) { + this._clearToolbarPickers(); + return; } - } - private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { - if (!optionGroup.when) { - return true; + if (!force) { + const allMatch = visibleGroups.length === this._toolbarPickerWidgets.size && visibleGroups.every(o => this._toolbarPickerWidgets.has(o.group.id)); + if (allMatch) { + return; + } } - const expr = ContextKeyExpr.deserialize(optionGroup.when); - return !expr || this.contextKeyService.contextMatchesRules(expr); - } - private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { - const selectedOption = this._selectedOptions.get(optionGroup.id); - if (selectedOption) { - return selectedOption; - } + this._clearToolbarPickers(); - if (this._newSession.value) { - const sessionOption = this.chatSessionsService.getSessionOption(this._newSession.value.resource, optionGroup.id); - if (!isString(sessionOption)) { - return sessionOption; - } + for (const option of visibleGroups) { + this._renderToolbarPickerWidget(option, session); } - - 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 _renderToolbarPickerWidget(option: ISessionOptionGroup, session: RemoteNewSession): void { + const { group: optionGroup, value: initialItem } = option; + + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); } - 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); + + const initialState = { group: optionGroup, item: initialItem }; + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => session.getOptionValue(optionGroup.id) ?? initialItem, + onDidChangeOption: emitter.event, + setOption: (item: IChatSessionProviderOptionItem) => { this._updateOptionContextKey(optionGroup.id, item.id); - this._optionEmitters.get(optionGroup.id)?.fire(item); - } - } + emitter.fire(item); + session.setOptionValue(optionGroup.id, item); + this._focusEditor(); + }, + getOptionGroup: () => { + const modelOpt = session.getModelOptionGroup(); + if (modelOpt?.group.id === optionGroup.id) { + return modelOpt.group; + } + return session.getOtherOptionGroups().find(o => o.group.id === optionGroup.id)?.group; + }, + getSessionResource: () => session.resource, + }; + + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate, undefined + ); + + this._toolbarPickerDisposables.add(widget); + this._toolbarPickerWidgets.set(optionGroup.id, widget); + + const slot = dom.append(this._toolbarPickersContainer!, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); } private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { @@ -736,23 +927,88 @@ class NewChatWidget extends Disposable { if (!emitter) { emitter = new Emitter(); this._optionEmitters.set(optionGroupId, emitter); - this._pickerWidgetDisposables.add(emitter); + this._toolbarPickerDisposables.add(emitter); } return emitter; } - private _clearExtensionPickers(): void { - this._pickerWidgetDisposables.clear(); - this._pickerWidgets.clear(); + private _clearToolbarPickers(): void { + this._toolbarPickerDisposables.clear(); + this._toolbarPickerWidgets.clear(); this._optionEmitters.clear(); + if (this._toolbarPickersContainer) { + dom.clearNode(this._toolbarPickersContainer); + } + } + + private _clearAllPickers(): void { + this._clearToolbarPickers(); if (this._folderPickerContainer) { this._folderPickerContainer.style.display = 'none'; } + if (this._repoPickerContainer) { + this._repoPickerContainer.style.display = 'none'; + } if (this._extensionPickersLeftContainer) { this._extensionPickersLeftContainer.style.display = 'none'; } - if (this._extensionPickersRightContainer) { - dom.clearNode(this._extensionPickersRightContainer); + } + + // --- Input History (IHistoryNavigationWidget) --- + + showPreviousValue(): void { + if (this._history.isAtStart()) { + return; + } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._draftState); + } + this._navigateHistory(true); + } + + showNextValue(): void { + if (this._history.isAtEnd()) { + return; + } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._draftState); + } + this._navigateHistory(false); + } + + private _updateDraftState(): void { + const attachments = [...this._contextAttachments.attachments]; + this._draftState = { + inputText: this._editor?.getModel()?.getValue() ?? '', + attachments, + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: this._currentLanguageModel.get(), + selections: this._editor?.getSelections() ?? [], + contrib: {}, + target: this._targetPicker.selectedTarget, + isolationMode: this._isolationModePicker.isolationMode, + branch: this._branchPicker.selectedBranch, + folderUri: this._folderPicker.selectedFolderUri?.toString(), + repo: this._repoPicker.selectedRepo, + }; + } + + 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) }); + } } } @@ -766,41 +1022,193 @@ class NewChatWidget extends Disposable { this._sendButton.enabled = !this._sending && hasText && !(this._newSession.value?.disabled ?? true); } - private _send(): void { - const query = this._editor.getModel()?.getValue().trim(); + private async _send(): Promise { + let query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; - if (!query || !session || session.disabled || this._sending) { + if (!query || !session || this._sending) { return; } + // If the session is disabled due to missing folder/repo, open the picker + if (session.disabled) { + if (!this._hasRequiredRepoOrFolderSelection(session.target)) { + this._openRepoOrFolderPicker(session.target); + } + return; + } + + // Check for slash commands first + if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) { + this._editor.getModel()?.setValue(''); + return; + } + + // Expand prompt/skill slash commands into a CLI-friendly reference + const expanded = this._slashCommandHandler?.tryExpandPromptSlashCommand(query); + if (expanded) { + query = expanded; + } + session.setQuery(query); session.setAttachedContext( this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined ); + if (this._draftState) { + this._history.append(this._draftState); + } + this._clearDraftState(); + 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(); + + try { + await this.sessionsManagementService.sendRequestForNewSession( + session.resource, + { + permissionLevel: this._permissionPicker.permissionLevel, + } + ); this._newSessionListener.clear(); this._contextAttachments.clear(); - }, e => { + this._editor.getModel()?.setValue(''); + } catch (e) { this.logService.error('Failed to send request:', e); - }).finally(() => { - this._sending = false; - this._editor.updateOptions({ readOnly: false }); - this._updateSendButtonState(); - this._updateInputLoadingState(); + } + + + this._sending = false; + this._editor.updateOptions({ readOnly: false }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + } + + /** + * 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(sessionType: AgentSessionProviders): boolean { + if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { + return !!this._folderPicker.selectedFolderUri; + } + return !!this._repoPicker.selectedRepo; + } + + private _openRepoOrFolderPicker(sessionType: AgentSessionProviders): void { + if (sessionType === AgentSessionProviders.Local || sessionType === AgentSessionProviders.Background) { + this._folderPicker.showPicker(); + } else { + this._repoPicker.showPicker(); + } + } + + 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._folderPicker.removeFromRecents(folderUri); + const previousFolderUri = this._newSession.value?.repoUri; + if (previousFolderUri) { + this._folderPicker.setSelectedFolder(previousFolderUri); + } else { + this._folderPicker.clearSelection(); + } + } + return !!trusted; + } + + + private _resolveDefaultTarget(options: INewChatWidgetOptions): AgentSessionProviders { + const draft = this._getDraftState(); + if (draft?.target && options.allowedTargets.includes(draft.target)) { + return draft.target; + } + return options.defaultTarget; + } + + 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)); + } + if (draft.selectedModel) { + const models = this._getAvailableModels(); + const model = models.find(m => m.identifier === draft.selectedModel?.identifier); + if (model) { + this._currentLanguageModel.set(model, undefined); + } + } + if (draft.isolationMode) { + if (this._isIsolationOptionEnabled) { + this._isolationModePicker.setPreferredIsolationMode(draft.isolationMode); + this._isolationModePicker.setIsolationMode(draft.isolationMode); + } else { + this._isolationModePicker.setPreferredIsolationMode('worktree'); + this._isolationModePicker.setIsolationMode('worktree'); + } + } + if (draft.branch) { + this._branchPicker.setPreferredBranch(draft.branch); + } + if (draft.folderUri) { + try { this._folderPicker.setSelectedFolder(URI.parse(draft.folderUri)); } catch { /* ignore */ } + } + if (draft.repo) { + this._repoPicker.setSelectedRepo(draft.repo); + } + } } - // --- Layout --- + private _getDraftState(): IDraftState | undefined { + const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + + private _clearDraftState(): void { + // Preserve picker preferences so they survive widget recreation + const target = this._targetPicker.selectedTarget; + const isLocal = target === AgentSessionProviders.Background; + const preserved: IDraftState = { + inputText: '', + attachments: [], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: this._draftState?.selectedModel, + selections: [], + contrib: {}, + target, + isolationMode: isLocal ? this._isolationModePicker.isolationMode : undefined, + branch: isLocal ? this._branchPicker.selectedBranch : undefined, + folderUri: isLocal ? this._folderPicker.selectedFolderUri?.toString() : undefined, + repo: isLocal ? undefined : this._repoPicker.selectedRepo, + }; + this._draftState = preserved; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(preserved), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + 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); + } + } layout(_height: number, _width: number): void { this._editor?.layout(); @@ -810,6 +1218,26 @@ class NewChatWidget extends Disposable { this._editor?.focus(); } + prefillInput(text: string): void { + const editor = this._editor; + const model = editor?.getModel(); + if (editor && model) { + model.setValue(text); + const lastLine = model.getLineCount(); + const maxColumn = model.getLineMaxColumn(lastLine); + editor.setPosition({ lineNumber: lastLine, column: maxColumn }); + editor.focus(); + } + } + + sendQuery(text: string): void { + const model = this._editor?.getModel(); + if (model) { + model.setValue(text); + this._send(); + } + } + updateAllowedTargets(targets: AgentSessionProviders[]): void { this._targetPicker.updateAllowedTargets(targets); } @@ -878,37 +1306,29 @@ export class NewChatViewPane extends ViewPane { this._widget?.focusInput(); } + prefillInput(text: string): void { + this._widget?.prefillInput(text); + } + + sendQuery(text: string): void { + this._widget?.sendQuery(text); + } + override setVisible(visible: boolean): void { super.setVisible(visible); if (visible) { this._widget?.focusInput(); } } -} -// #endregion + override saveState(): void { + this._widget?.saveState(); + } -/** - * Check whether an option group represents the model picker. - * The convention is `id: 'models'` but extensions may use different IDs - * per session type, so we also fall back to name matching. - */ -function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { - if (group.id === 'models') { - return true; + override dispose(): void { + this._widget?.saveState(); + super.dispose(); } - const nameLower = group.name.toLowerCase(); - return nameLower === 'model' || nameLower === 'models'; } -/** - * Check whether an option group represents a repository or folder picker. - * These are placed on the right side of the pickers row. - */ -function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean { - const idLower = group.id.toLowerCase(); - const nameLower = group.name.toLowerCase(); - return idLower === 'repositories' || idLower === 'folders' || - nameLower === 'repository' || nameLower === 'repositories' || - nameLower === 'folder' || nameLower === 'folders'; -} +// #endregion diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index 2e601215b535a..232f56251cc88 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -6,15 +6,24 @@ 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 { IChatSessionProviderOptionGroup, 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 { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; -export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled'; +export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled' | 'agent'; + +/** + * Represents a resolved option group with its current selected value. + */ +export interface ISessionOptionGroup { + readonly group: IChatSessionProviderOptionGroup; + readonly value: IChatSessionProviderOptionItem | undefined; +} /** * A new session represents a session being configured before the first @@ -28,6 +37,7 @@ export interface INewSession extends IDisposable { readonly isolationMode: IsolationMode; readonly branch: string | undefined; readonly modelId: string | undefined; + readonly mode: IChatMode | undefined; readonly query: string | undefined; readonly attachedContext: IChatRequestVariableEntry[] | undefined; readonly selectedOptions: ReadonlyMap; @@ -37,6 +47,7 @@ export interface INewSession extends IDisposable { setIsolationMode(mode: IsolationMode): void; setBranch(branch: string | undefined): void; setModelId(modelId: string | undefined): void; + setMode(mode: IChatMode | undefined): void; setQuery(query: string): void; setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void; setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void; @@ -45,6 +56,7 @@ export interface INewSession extends IDisposable { const REPOSITORY_OPTION_ID = 'repository'; const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; +const AGENT_OPTION_ID = 'agent'; /** * Local new session for Background agent sessions. @@ -57,6 +69,7 @@ export class LocalNewSession extends Disposable implements INewSession { private _isolationMode: IsolationMode = 'worktree'; private _branch: string | undefined; private _modelId: string | undefined; + private _mode: IChatMode | undefined; private _query: string | undefined; private _attachedContext: IChatRequestVariableEntry[] | undefined; @@ -70,6 +83,7 @@ export class LocalNewSession extends Disposable implements INewSession { get isolationMode(): IsolationMode { return this._isolationMode; } get branch(): string | undefined { return this._branch; } get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return this._mode; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } get disabled(): boolean { @@ -85,8 +99,8 @@ export class LocalNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, defaultRepoUri: URI | undefined, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ILogService private readonly logService: ILogService, ) { super(); if (defaultRepoUri) { @@ -126,6 +140,15 @@ export class LocalNewSession extends Disposable implements INewSession { this._modelId = modelId; } + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + const modeName = mode?.isBuiltin ? undefined : mode?.name.get(); + this.setOption(AGENT_OPTION_ID, modeName ?? ''); + } + } + setQuery(query: string): void { this._query = query; } @@ -149,13 +172,12 @@ export class LocalNewSession extends Disposable implements INewSession { /** * Remote new session for Cloud agent sessions. - * Fires `onDidChange` and notifies the extension service when `repoUri` changes. - * Ignores `isolationMode` (not relevant for cloud). + * Manages extension-driven option groups (models, etc.) and their values. + * Fires events for option group changes. */ 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; @@ -163,33 +185,43 @@ export class RemoteNewSession extends Disposable implements INewSession { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; + private readonly _onDidChangeOptionGroups = this._register(new Emitter()); + readonly onDidChangeOptionGroups: Event = this._onDidChangeOptionGroups.event; + readonly selectedOptions = new Map(); get repoUri(): URI | undefined { return this._repoUri; } - get isolationMode(): IsolationMode { return this._isolationMode; } + get isolationMode(): IsolationMode { return 'worktree'; } get branch(): string | undefined { return undefined; } get modelId(): string | undefined { return this._modelId; } + get mode(): 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._hasRepositoryOption(); + return !this._repoUri && !this.selectedOptions.has('repositories'); } + private readonly _whenClauseKeys = new Set(); + constructor( readonly resource: URI, readonly target: AgentSessionProviders, - private readonly chatSessionsService: IChatSessionsService, - private readonly logService: ILogService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILogService private readonly logService: ILogService, ) { super(); - // Listen for extension-driven option group and session option changes + this._updateWhenClauseKeys(); + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._updateWhenClauseKeys(); + this._onDidChangeOptionGroups.fire(); this._onDidChange.fire('options'); })); - this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { - if (isEqual(this.resource, e)) { - this._onDidChange.fire('options'); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { + this._onDidChangeOptionGroups.fire(); } })); } @@ -198,21 +230,27 @@ export class RemoteNewSession extends Disposable implements INewSession { this._repoUri = uri; this._onDidChange.fire('repoUri'); this._onDidChange.fire('disabled'); - this.setOption('repository', uri.fsPath); + const id = uri.path.substring(1); + this.setOption('repositories', { id, name: id }); } setIsolationMode(_mode: IsolationMode): void { - // No-op for remote sessions — isolation mode is not relevant + // No-op for remote sessions } setBranch(_branch: string | undefined): void { - // No-op for remote sessions — branch is not relevant + // No-op for remote sessions } setModelId(modelId: string | undefined): void { this._modelId = modelId; } + setMode(_mode: IChatMode | undefined): void { + // Intentionally a no-op: remote sessions do not support client-side mode selection. + // Any mode or behavior differences are determined by the remote session provider/server. + } + setQuery(query: string): void { this._query = query; } @@ -233,7 +271,99 @@ export class RemoteNewSession extends Disposable implements INewSession { ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); } - private _hasRepositoryOption(): boolean { - return this.selectedOptions.has('repositories'); + // --- 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]; + } +} + +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'; } diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 69c0e8e2497dd..8cc51fa370ea1 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -6,23 +6,246 @@ import { PromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.js'; import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.js'; import { Event } from '../../../../base/common/event.js'; -import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { basename, dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { HOOKS_SOURCE_FOLDER, SKILL_FILENAME, getCleanPromptName } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { IAgentSkill, IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../../chat/common/builtinPromptsStorage.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; + +/** URI root for built-in prompts bundled with the Sessions app. */ +export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts'); + +/** URI root for built-in skills bundled with the Sessions app. */ +export const BUILTIN_SKILLS_URI = FileAccess.asFileUri('vs/sessions/skills'); export class AgenticPromptsService extends PromptsService { + private _copilotRoot: URI | undefined; + private _builtinPromptsCache: Map> | undefined; + private _builtinSkillsCache: Promise | undefined; + protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); } + + private getCopilotRoot(): URI { + if (!this._copilotRoot) { + const pathService = this.instantiationService.invokeFunction(accessor => accessor.get(IPathService)); + this._copilotRoot = joinPath(pathService.userHome({ preferLocal: true }), '.copilot'); + } + return this._copilotRoot; + } + + /** + * Returns built-in prompt files bundled with the Sessions app. + */ + private async getBuiltinPromptFiles(type: PromptsType): Promise { + if (type !== PromptsType.prompt) { + return []; + } + + if (!this._builtinPromptsCache) { + this._builtinPromptsCache = new Map(); + } + + let cached = this._builtinPromptsCache.get(type); + if (!cached) { + cached = this.discoverBuiltinPrompts(type); + this._builtinPromptsCache.set(type, cached); + } + return cached; + } + + private async discoverBuiltinPrompts(type: PromptsType): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + const promptsDir = FileAccess.asFileUri('vs/sessions/prompts'); + try { + const stat = await fileService.resolve(promptsDir); + if (!stat.children) { + return []; + } + return stat.children + .filter(child => !child.isDirectory && child.name.endsWith('.prompt.md')) + .map(child => ({ uri: child.resource, storage: BUILTIN_STORAGE, type })); + } catch { + return []; + } + } + + //#region Built-in Skills + + /** + * Returns built-in skill metadata, discovering and parsing SKILL.md files + * bundled in the `vs/sessions/skills/` directory. + */ + private async getBuiltinSkills(): Promise { + if (!this._builtinSkillsCache) { + this._builtinSkillsCache = this.discoverBuiltinSkills(); + } + return this._builtinSkillsCache; + } + + /** + * Discovers built-in skills from `vs/sessions/skills/{name}/SKILL.md`. + * Each subdirectory containing a SKILL.md is treated as a skill. + */ + private async discoverBuiltinSkills(): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + try { + const stat = await fileService.resolve(BUILTIN_SKILLS_URI); + if (!stat.children) { + return []; + } + + const skills: IAgentSkill[] = []; + for (const child of stat.children) { + if (!child.isDirectory) { + continue; + } + const skillFileUri = joinPath(child.resource, SKILL_FILENAME); + try { + const parsed = await this.parseNew(skillFileUri, CancellationToken.None); + const rawName = parsed.header?.name; + const rawDescription = parsed.header?.description; + if (!rawName || !rawDescription) { + continue; + } + const name = sanitizeSkillText(rawName, 64); + const description = sanitizeSkillText(rawDescription, 1024); + const folderName = basename(child.resource); + if (name !== folderName) { + continue; + } + skills.push({ + uri: skillFileUri, + storage: BUILTIN_STORAGE as PromptsStorage, + name, + description, + disableModelInvocation: parsed.header?.disableModelInvocation === true, + userInvocable: parsed.header?.userInvocable !== false, + }); + } catch (e) { + this.logger.warn(`[discoverBuiltinSkills] Failed to parse built-in skill: ${skillFileUri}`, e instanceof Error ? e.message : String(e)); + } + } + return skills; + } catch { + return []; + } + } + + /** + * Returns built-in skill file paths for listing in the UI. + */ + private async getBuiltinSkillPaths(): Promise { + const skills = await this.getBuiltinSkills(); + return skills.map(s => ({ + uri: s.uri, + storage: BUILTIN_STORAGE, + type: PromptsType.skill, + })); + } + + /** + * Override to include built-in skills, appending them with lowest priority. + * Skills from any other source (workspace, user, extension, internal) take precedence. + */ + public override async findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise { + const baseResult = await super.findAgentSkills(token, sessionResource); + if (baseResult === undefined) { + return undefined; + } + + const builtinSkills = await this.getBuiltinSkills(); + if (builtinSkills.length === 0) { + return baseResult; + } + + // Collect names already present from other sources + const existingNames = new Set(baseResult.map(s => s.name)); + const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name)); + if (nonOverridden.length === 0) { + return baseResult; + } + + return [...baseResult, ...nonOverridden]; + } + + //#endregion + + /** + * Override to include built-in prompts and built-in skills, filtering out + * those overridden by user or workspace items with the same name. + */ + public override async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { + const baseResults = await super.listPromptFiles(type, token); + + let builtinItems: readonly IBuiltinPromptPath[]; + if (type === PromptsType.skill) { + builtinItems = await this.getBuiltinSkillPaths(); + } else { + builtinItems = await this.getBuiltinPromptFiles(type); + } + if (builtinItems.length === 0) { + return baseResults; + } + + // Collect names of user/workspace items to detect overrides + const overriddenNames = new Set(); + for (const p of baseResults) { + if (p.storage === PromptsStorage.local || p.storage === PromptsStorage.user) { + overriddenNames.add(type === PromptsType.skill ? basename(dirname(p.uri)) : getCleanPromptName(p.uri)); + } + } + + const nonOverridden = builtinItems.filter( + p => !overriddenNames.has(type === PromptsType.skill ? basename(dirname(p.uri)) : getCleanPromptName(p.uri)) + ); + // Built-in items use BUILTIN_STORAGE ('builtin') which is not in the + // core IPromptPath union but is handled by the sessions UI layer. + return [...baseResults, ...nonOverridden] as readonly IPromptPath[]; + } + + public override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + if (storage === BUILTIN_STORAGE) { + if (type === PromptsType.skill) { + return this.getBuiltinSkillPaths() as Promise; + } + return this.getBuiltinPromptFiles(type) as Promise; + } + return super.listPromptFilesForStorage(type, storage, token); + } + + /** + * Override to use ~/.copilot as the user-level source folder for creation, + * instead of the VS Code profile's promptsHome. + */ + public override async getSourceFolders(type: PromptsType): Promise { + const folders = await super.getSourceFolders(type); + const copilotRoot = this.getCopilotRoot(); + // Replace any user-storage folders with the CLI-accessible ~/.copilot root + return folders.map(folder => { + if (folder.storage === PromptsStorage.user) { + const subfolder = getCliUserSubfolder(type); + return subfolder + ? { ...folder, uri: joinPath(copilotRoot, subfolder) } + : folder; + } + return folder; + }); + } } class AgenticPromptFilesLocator extends PromptFilesLocator { @@ -36,7 +259,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 +270,8 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { searchService, userDataService, logService, - pathService + pathService, + workspaceTrustManagementService ); } @@ -64,7 +289,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 +302,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 +315,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..096104d345432 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -5,18 +5,26 @@ 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 } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { localize, localize2 } from '../../../../nls.js'; import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IActiveSessionItem, IsActiveSessionBackgroundProviderContext, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.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, ITaskEntry, TaskStorageTarget } from './sessionsConfigurationService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IRunScriptCustomTaskWidgetResult, RunScriptCustomTaskWidget } from './runScriptCustomTaskWidget.js'; +import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; @@ -25,8 +33,9 @@ export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdow // Action IDs const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; +const RUN_SCRIPT_ACTION_PRIMARY_ID = 'workbench.action.agentSessions.runScriptPrimary'; const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; - +const GENERATE_RUN_ACTION_ID = 'workbench.action.agentSessions.generateRunAction'; function getTaskDisplayLabel(task: ITaskEntry): string { if (task.label && task.label.length > 0) { return task.label; @@ -43,6 +52,19 @@ 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); +} + interface IRunScriptActionContext { readonly session: IActiveSessionItem; readonly tasks: readonly ITaskEntry[]; @@ -61,8 +83,11 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor( @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IViewsService private readonly _viewsService: IViewsService, ) { super(); @@ -93,6 +118,40 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr private _registerActions(): void { const that = this; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_SCRIPT_ACTION_PRIMARY_ID, + title: { value: localize('runPrimaryTask', 'Run Primary Task'), original: 'Run Primary Task' }, + icon: Codicon.play, + category: SessionsCategories.Sessions, + f1: true, + }); + } + + async run(): Promise { + const activeState = that._activeRunState.get(); + if (!activeState) { + return; + } + + const { tasks, session, lastRunTaskLabel } = activeState; + if (tasks.length === 0) { + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } + return; + } + + const mruIndex = lastRunTaskLabel !== undefined + ? tasks.findIndex(t => t.label === lastRunTaskLabel) + : -1; + const primaryTask = tasks[mruIndex >= 0 ? mruIndex : 0]; + await that._sessionsConfigService.runTask(primaryTask, session); + } + })); + this._register(autorun(reader => { const activeState = this._activeRunState.read(reader); if (!activeState) { @@ -111,13 +170,15 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; const actionId = `${RUN_SCRIPT_ACTION_ID}.${i}`; + const isPrimary = i === (mruIndex >= 0 ? mruIndex : 0); reader.store.add(registerAction2(class extends Action2 { constructor() { super({ id: actionId, title: getTaskDisplayLabel(task), - tooltip: localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)), + tooltip: !isPrimary ? localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)) + : localize('runActionTooltipKeybinding', "Run '{0}' in terminal ({1})", getTaskDisplayLabel(task), that._keybindingService.lookupKeybinding(RUN_SCRIPT_ACTION_PRIMARY_ID)?.getLabel() ?? ''), icon: Codicon.play, category: SessionsCategories.Sessions, menu: [{ @@ -140,7 +201,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Run Action..."), + title: localize2('configureDefaultRunAction', "Add Action..."), category: SessionsCategories.Sessions, icon: Codicon.play, precondition: configureScriptPrecondition, @@ -153,18 +214,47 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } async run(): Promise { - await that._showConfigureQuickPick(session); + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } + } + })); + + // Generate new action via Copilot (only shown when there is an active session) + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: GENERATE_RUN_ACTION_ID, + title: localize2('generateRunAction', "Generate New Action..."), + category: SessionsCategories.Sessions, + precondition: IsActiveSessionBackgroundProviderContext, + menu: [{ + id: RunScriptDropdownMenuId, + group: tasks.length === 0 ? 'navigation' : '1_configure', + order: 1 + }] + }); + } + + async run(): Promise { + if (session.isUntitled) { + const viewPane = that._viewsService.getViewWithId(SessionsViewId); + viewPane?.sendQuery('/generate-run-commands'); + } else { + const widget = that._chatWidgetService.getWidgetBySessionResource(session.resource); + await widget?.acceptInput('/generate-run-commands'); + } } })); })); } - private async _showConfigureQuickPick(session: IActiveSessionItem): Promise { + private async _showConfigureQuickPick(session: IActiveSessionItem): 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 { @@ -182,12 +272,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr 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, }); } } @@ -197,96 +287,135 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr }); if (!picked) { - return; + return undefined; } const pickedItem = picked as ITaskPickItem; if (pickedItem.task) { - // Existing task — set inSessions: true - await this._sessionsConfigService.addTaskToSessions(pickedItem.task, session, pickedItem.source ?? 'workspace'); + return this._showCustomCommandInput(session, { task: pickedItem.task, target: pickedItem.source ?? 'workspace' }); } else { // Custom command path - await this._showCustomCommandInput(session); + return this._showCustomCommandInput(session); } } - private async _showCustomCommandInput(session: IActiveSessionItem): Promise { - const command = await this._quickInputService.input({ - placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), - prompt: localize('enterCommandPrompt', "This command will be run as a task in the integrated terminal") - }); - - if (!command) { - return; + private async _showCustomCommandInput(session: IActiveSessionItem, existingTask?: INonSessionTaskEntry): Promise { + const taskConfiguration = await this._showCustomCommandWidget(session, existingTask); + if (!taskConfiguration) { + return undefined; } - const target = await this._pickStorageTarget(session); - if (!target) { - return; + if (existingTask) { + 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; - - interface IStorageTargetItem extends IQuickPickItem { - target: TaskStorageTarget; - } + private _showCustomCommandWidget(session: IActiveSessionItem, existingTask?: INonSessionTaskEntry): Promise { + const workspaceTargetDisabledReason = !(session.worktree ?? session.repository) + ? localize('workspaceStorageUnavailableTooltip', "Workspace storage is unavailable for this session") + : undefined; + + return new Promise(resolve => { + const disposables = new DisposableStore(); + let settled = false; + + const quickWidget = disposables.add(this._quickInputService.createQuickWidget()); + quickWidget.title = existingTask + ? localize('addExistingActionWidgetTitle', "Add Existing Action...") + : localize('addActionWidgetTitle', "Add Action..."); + quickWidget.description = existingTask + ? localize('addExistingActionWidgetDescription', "Enable an existing task for sessions and configure when it should run") + : localize('addActionWidgetDescription', "Create a shell task and configure how it should be saved and run"); + quickWidget.ignoreFocusOut = true; + const widget = disposables.add(new RunScriptCustomTaskWidget({ + label: existingTask?.task.label, + labelDisabledReason: existingTask ? localize('existingTaskLabelLocked', "This name comes from an existing task and cannot be changed here.") : undefined, + command: existingTask ? getTaskCommandPreview(existingTask.task) : undefined, + commandDisabledReason: existingTask ? localize('existingTaskCommandLocked', "This command comes from an existing task and cannot be changed here.") : undefined, + target: existingTask?.target, + targetDisabledReason: existingTask ? localize('existingTaskTargetLocked', "This existing task cannot be moved between workspace and user storage.") : workspaceTargetDisabledReason, + runOn: existingTask?.task.runOptions?.runOn === 'worktreeCreated' ? 'worktreeCreated' : undefined, + })); + quickWidget.widget = widget.domNode; - const items: IStorageTargetItem[] = [ - { - target: 'user', - label: localize('storeInUserSettings', "User Settings"), - description: localize('storeInUserSettingsDesc', "Available in all sessions"), - }, - 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, - } - ]; - - 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); + const complete = (result: IRunScriptCustomTaskWidgetResult | undefined) => { + if (settled) { + return; } - }); - picker.onDidHide(() => { - picker.dispose(); - resolve(undefined); - }); - picker.show(); + settled = true; + resolve(result); + quickWidget.hide(); + }; + + disposables.add(widget.onDidSubmit(result => complete(result))); + disposables.add(widget.onDidCancel(() => complete(undefined))); + disposables.add(quickWidget.onDidHide(() => { + if (!settled) { + settled = true; + resolve(undefined); + } + disposables.dispose(); + })); + + quickWidget.show(); + widget.focus(); }); } } -// 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 Script 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..32259cd6446d6 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.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 './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; +} + +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 _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._selectedTarget = state.target ?? (state.targetDisabledReason ? 'user' : 'workspace'); + + 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 action (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 action when the session worktree is created"))); + this._register(dom.addDisposableListener(runOnText, dom.EventType.CLICK, () => this._runOnCheckbox.checked = !this._runOnCheckbox.checked)); + + const storageSection = dom.append(this.domNode, dom.$('.run-script-action-section')); + dom.append(storageSection, dom.$('div.run-script-action-label', undefined, localize('storageLabel', "Save In"))); + const storageDisabledReason = state.targetDisabledReason; + const workspaceTargetDisabled = !!storageDisabledReason; + this._storageOptions = this._register(new Radio({ + items: [ + { + text: localize('workspaceStorageLabel', "Workspace"), + tooltip: storageDisabledReason ?? localize('workspaceStorageTooltip', "Save this action in the current workspace"), + isActive: this._selectedTarget === 'workspace', + disabled: workspaceTargetDisabled, + }, + { + text: localize('userStorageLabel', "User"), + tooltip: this._targetLocked ? storageDisabledReason : localize('userStorageTooltip', "Save this action in your user tasks and make it available in all sessions"), + isActive: this._selectedTarget === 'user', + disabled: this._targetLocked, + } + ] + })); + this._storageOptions.domNode.setAttribute('aria-label', localize('storageAriaLabel', "Task storage target")); + storageSection.appendChild(this._storageOptions.domNode); + if (storageDisabledReason && !this._targetLocked) { + dom.append(storageSection, dom.$('div.run-script-action-hint', undefined, storageDisabledReason)); + } + + const buttonRow = dom.append(this.domNode, dom.$('.run-script-action-buttons')); + this._cancelButton = this._register(new Button(buttonRow, { ...defaultButtonStyles, secondary: true })); + this._cancelButton.label = localize('cancelAddAction', "Cancel"); + this._submitButton = this._register(new Button(buttonRow, defaultButtonStyles)); + this._submitButton.label = localize('confirmAddAction', "Add Action"); + + this._register(this._labelInput.onDidChange(() => this._updateButtonEnablement())); + this._register(this._commandInput.onDidChange(() => this._updateButtonEnablement())); + this._register(this._storageOptions.onDidSelect(index => { + this._selectedTarget = index === 0 ? 'workspace' : 'user'; + })); + 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._updateButtonEnablement(); + } + + 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 _updateButtonEnablement(): void { + this._submitButton.enabled = this._commandInput.value.trim().length > 0; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index 1d762632f9f71..ecf9e9b782f9d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -5,40 +5,15 @@ 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 { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.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 --- @@ -161,33 +136,27 @@ export type IsolationMode = 'worktree' | 'workspace'; export class IsolationModePicker extends Disposable { private _isolationMode: IsolationMode = 'worktree'; - private _newSession: INewSession | undefined; + private _preferredIsolationMode: IsolationMode | undefined; private _repository: IGitRepository | undefined; + private _enabled: boolean = true; 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; + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; get isolationMode(): IsolationMode { return this._isolationMode; } constructor( - @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, ) { 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'. @@ -195,78 +164,150 @@ export class IsolationModePicker extends Disposable { setRepository(repository: IGitRepository | undefined): void { this._repository = repository; if (repository) { - this._setMode('worktree'); + const preferred = this._preferredIsolationMode; + this._preferredIsolationMode = undefined; + this._setMode(preferred ?? this._isolationMode); } else if (this._isolationMode === 'worktree') { + this._preferredIsolationMode ??= this._isolationMode; this._setMode('workspace'); } - this._renderDropdown(); + this._updateTriggerLabel(); } /** - * Renders the isolation mode dropdown into the given container. + * Renders the isolation mode picker into the given container. */ render(container: HTMLElement): void { - this._container = container; - this._dropdownContainer = dom.append(container, dom.$('.sessions-chat-local-mode-left')); - this._renderDropdown(); + 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(); + } + })); + } + + /** + * Sets a preferred isolation mode to apply when a repository is set. + */ + setPreferredIsolationMode(mode: IsolationMode): void { + this._preferredIsolationMode = mode; + } + + /** + * Programmatically set the isolation mode. + */ + setIsolationMode(mode: IsolationMode): void { + this._setMode(mode); } /** * Shows or hides the picker. */ setVisible(visible: boolean): void { - if (this._container) { - this._container.style.visibility = visible ? '' : 'hidden'; + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; } } - private _renderDropdown(): void { - if (!this._dropdownContainer) { + /** + * Enables or disables the picker. When disabled, the picker is shown + * but cannot be interacted with. + */ + setEnabled(enabled: boolean): void { + this._enabled = enabled; + this._updateTriggerLabel(); + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible || !this._repository || !this._enabled) { 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 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 modeAction = toAction({ id: 'isolationMode', label: modeLabel, run: () => { } }); - const modeDropdown = this._renderDisposables.add(new LabeledDropdownMenuActionViewItem( - modeAction, + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (mode) => { + this.actionWidgetService.hide(); + this._setMode(mode); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'isolationModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], { - 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'), - }), - ], + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('isolationModePicker.ariaLabel', "Isolation Mode"), }, - 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(); + this._updateTriggerLabel(); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + const isDisabled = !this._repository; + const modeIcon = this._isolationMode === 'worktree' ? Codicon.worktree : Codicon.folder; + const modeLabel = this._isolationMode === 'worktree' + ? localize('isolationMode.worktree', "Worktree") + : localize('isolationMode.folder', "Folder"); + + 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)); + + this._slotElement?.classList.toggle('disabled', isDisabled || !this._enabled); + if (this._triggerElement) { + this._triggerElement.tabIndex = (!isDisabled && this._enabled) ? 0 : -1; + this._triggerElement.setAttribute('aria-disabled', String(isDisabled || !this._enabled)); } } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index 4183a311f60a2..5ba62f2c0a0c5 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { autorun, 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'; @@ -20,6 +20,11 @@ import { ITerminalInstance, ITerminalService } from '../../../../workbench/contr import { CommandString } from '../../../../workbench/contrib/tasks/common/taskConfiguration.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,20 @@ 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; +} + interface ITasksJson { version?: string; tasks?: ITaskEntry[]; @@ -55,19 +67,19 @@ export interface ISessionsConfigurationService { * Returns tasks that do NOT have `inSessions: true` — used as * suggestions in the "Add Run Action" picker. */ - getNonSessionTasks(session: IActiveSessionItem): Promise; + getNonSessionTasks(session: IActiveSessionItem): 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: IActiveSessionItem, 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: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise; /** * Runs a task entry in a terminal, resolving the correct platform @@ -88,11 +100,13 @@ export class SessionsConfigurationService extends Disposable implements ISession declare readonly _serviceBrand: undefined; private static readonly _LAST_RUN_TASK_LABELS_KEY = 'agentSessions.lastRunTaskLabels'; + private static readonly _SUPPORTED_TASK_TYPES = new Set(['shell', 'npm']); 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 _knownSessionWorktrees = new Map(); private readonly _lastRunTaskLabels: Map; private readonly _lastRunTaskObservables = new Map>>(); @@ -109,6 +123,11 @@ export class SessionsConfigurationService extends Disposable implements ISession ) { super(); this._lastRunTaskLabels = this._loadLastRunTaskLabels(); + + this._register(autorun(reader => { + const activeSession = this._sessionsManagementService.activeSession.read(reader); + this._handleActiveSessionChange(activeSession); + })); } getSessionTasks(session: IActiveSessionItem): IObservable { @@ -124,12 +143,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: IActiveSessionItem): 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: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { return; @@ -142,28 +182,39 @@ export class SessionsConfigurationService extends Disposable implements ISession return; } - await this._jsonEditingService.write(tasksJsonUri, [ - { path: ['tasks', index, 'inSessions'], value: true } - ], true); + const edits: { path: (string | number)[]; value: unknown }[] = [ + { path: ['tasks', index, 'inSessions'], value: true }, + ]; + + if (options) { + edits.push({ + path: ['tasks', index, 'runOptions'], + value: options.runOn && options.runOn !== 'default' ? { runOn: options.runOn } : undefined, + }); + } + + await this._jsonEditingService.write(tasksJsonUri, edits, true); if (target === 'workspace') { await this._commitTasksFile(session); } } - async createAndAddTask(command: string, session: IActiveSessionItem, target: TaskStorageTarget): Promise { + async createAndAddTask(label: string | undefined, command: string, session: IActiveSessionItem, target: TaskStorageTarget, options?: ITaskRunOptions): Promise { const tasksJsonUri = this._getTasksJsonUri(session, target); if (!tasksJsonUri) { - return; + return undefined; } const tasksJson = await this._readTasksJson(tasksJsonUri); const tasks = tasksJson.tasks ?? []; + const resolvedLabel = label?.trim() || command; const newTask: ITaskEntry = { - label: command, + label: resolvedLabel, type: 'shell', command, inSessions: true, + ...(options?.runOn && options.runOn !== 'default' ? { runOptions: { runOn: options.runOn } } : {}), }; await this._jsonEditingService.write(tasksJsonUri, [ @@ -174,6 +225,8 @@ export class SessionsConfigurationService extends Disposable implements ISession if (target === 'workspace') { await this._commitTasksFile(session); } + + return newTask; } async runTask(task: ITaskEntry, session: IActiveSessionItem): Promise { @@ -265,7 +318,7 @@ export class SessionsConfigurationService extends Disposable implements ISession if (workspaceUri) { const workspaceJson = await this._readTasksJson(workspaceUri); if (workspaceJson.tasks) { - result.push(...workspaceJson.tasks); + result.push(...workspaceJson.tasks.filter(t => this._isSupportedTask(t))); } } @@ -274,24 +327,89 @@ export class SessionsConfigurationService extends Disposable implements ISession if (userUri) { const userJson = await this._readTasksJson(userUri); if (userJson.tasks) { - result.push(...userJson.tasks); + result.push(...userJson.tasks.filter(t => this._isSupportedTask(t))); } } return result; } + private _isSupportedTask(task: ITaskEntry): boolean { + return !!task.type && SessionsConfigurationService._SUPPORTED_TASK_TYPES.has(task.type); + } + private _resolveCommand(task: ITaskEntry): string | undefined { + if (task.type === 'npm') { + if (!task.script) { + return undefined; + } + const base = task.path + ? `npm --prefix ${task.path} run ${task.script}` + : `npm run ${task.script}`; + return this._appendArgs(base, task.args); + } + + let command: string | undefined; + let platformArgs: CommandString[] | undefined; + if (isWindows && task.windows?.command) { - return task.windows.command; + command = task.windows.command; + platformArgs = task.windows.args; + } else if (isMacintosh && task.osx?.command) { + command = task.osx.command; + platformArgs = task.osx.args; + } else if (!isWindows && !isMacintosh && task.linux?.command) { + command = task.linux.command; + platformArgs = task.linux.args; + } else { + command = task.command; } - if (isMacintosh && task.osx?.command) { - return task.osx.command; + + // Platform-specific args override task-level args + const args = platformArgs ?? task.args; + return this._appendArgs(command, args); + } + + private _appendArgs(command: string | undefined, args: CommandString[] | undefined): string | undefined { + if (!command) { + return undefined; + } + if (!args || args.length === 0) { + return command; } - if (!isWindows && !isMacintosh && task.linux?.command) { - return task.linux.command; + const resolvedArgs = args.map(a => CommandString.value(a)).join(' '); + return `${command} ${resolvedArgs}`; + } + + private _handleActiveSessionChange(session: IActiveSessionItem | undefined): void { + if (!session) { + return; + } + + const sessionKey = session.resource.toString(); + const currentWorktree = session.worktree?.toString(); + if (!this._knownSessionWorktrees.has(sessionKey)) { + this._knownSessionWorktrees.set(sessionKey, currentWorktree); + return; + } + + const previousWorktree = this._knownSessionWorktrees.get(sessionKey); + this._knownSessionWorktrees.set(sessionKey, currentWorktree); + if (!currentWorktree || previousWorktree === currentWorktree) { + return; + } + + void this._runWorktreeCreatedTasks(session); + } + + private async _runWorktreeCreatedTasks(session: IActiveSessionItem): Promise { + const tasks = await this._readAllTasks(session); + for (const task of tasks) { + if (!task.inSessions || task.runOptions?.runOn !== 'worktreeCreated') { + continue; + } + await this.runTask(task, session); } - return task.command; } private _ensureFileWatch(folder: URI): void { @@ -321,12 +439,12 @@ 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 = (tasksJson.tasks ?? []).filter(t => t.inSessions && this._isSupportedTask(t)); // 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 = (userJson.tasks ?? []).filter(t => t.inSessions && this._isSupportedTask(t)); transaction(tx => this._sessionTasks.set([...sessionTasks, ...userSessionTasks], tx)); } diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts new file mode 100644 index 0000000000000..4cf481f915e2f --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { themeColorFromId } from '../../../../base/common/themables.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { IDecorationOptions } from '../../../../editor/common/editorCommon.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { getWordAtText } from '../../../../editor/common/core/wordHelper.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js'; +import { localize } from '../../../../nls.js'; +import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; + +/** + * Static command ID used by completion items to trigger immediate slash command execution, + * mirroring the pattern of core's `ChatSubmitAction` for `executeImmediately` commands. + */ +export const SESSIONS_EXECUTE_SLASH_COMMAND_ID = 'sessions.chat.executeSlashCommand'; + +CommandsRegistry.registerCommand(SESSIONS_EXECUTE_SLASH_COMMAND_ID, (_, handler: SlashCommandHandler, slashCommandStr: string) => { + handler.tryExecuteSlashCommand(slashCommandStr); + handler.clearInput(); +}); + +/** + * Minimal slash command descriptor for the sessions new-chat widget. + * Self-contained copy of the essential fields from core's `IChatSlashData` + * to avoid a direct dependency on the workbench chat slash command service. + */ +interface ISessionsSlashCommandData { + readonly command: string; + readonly detail: string; + readonly sortText?: string; + readonly executeImmediately?: boolean; + readonly execute: (args: string) => void; +} + +/** + * Manages slash commands for the sessions new-chat input widget — registration, + * autocompletion, decorations (syntax highlighting + placeholder text), and execution. + */ +export class SlashCommandHandler extends Disposable { + + private static readonly _slashDecoType = 'sessions-slash-command'; + private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder'; + private static _slashDecosRegistered = false; + + private readonly _slashCommands: ISessionsSlashCommandData[] = []; + private _cachedPromptCommands: readonly IChatPromptSlashCommand[] = []; + + constructor( + private readonly _editor: CodeEditorWidget, + @ICommandService private readonly commandService: ICommandService, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IThemeService private readonly themeService: IThemeService, + @IAICustomizationWorkspaceService private readonly aiCustomizationWorkspaceService: IAICustomizationWorkspaceService, + @IPromptsService private readonly promptsService: IPromptsService, + ) { + super(); + this._registerSlashCommands(); + this._registerCompletions(); + this._registerDecorations(); + this._refreshPromptCommands(); + this._register(this.promptsService.onDidChangeSlashCommands(() => this._refreshPromptCommands())); + } + + clearInput(): void { + this._editor.getModel()?.setValue(''); + } + + private _refreshPromptCommands(): void { + this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(CancellationToken.None).then(commands => { + this._cachedPromptCommands = commands; + this._updateDecorations(); + }, () => { /* swallow errors from stale refresh */ }); + } + + /** + * Attempts to parse and execute a slash command from the input. + * Returns `true` if a command was handled. + */ + tryExecuteSlashCommand(query: string): boolean { + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); + if (!match) { + return false; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + if (!slashCommand) { + return false; + } + + slashCommand.execute(match[2]?.trim() ?? ''); + return true; + } + + /** + * If the query starts with a prompt/skill slash command (e.g. `/my-prompt args`), + * expands it into a CLI-friendly markdown reference so the agent can locate the + * file. Returns `undefined` when the query is not a prompt slash command. + */ + tryExpandPromptSlashCommand(query: string): string | undefined { + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); + if (!match) { + return undefined; + } + + const commandName = match[1]; + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!promptCommand) { + return undefined; + } + + const args = match[2]?.trim() ?? ''; + const uri = promptCommand.promptPath.uri; + const typeLabel = promptCommand.promptPath.type === PromptsType.skill ? 'skill' : 'prompt file'; + const expanded = `Use the ${typeLabel} located at [${promptCommand.name}](${uri.toString()}).`; + return args ? `${expanded} ${args}` : expanded; + } + + private _registerSlashCommands(): void { + const openSection = (section: AICustomizationManagementSection) => + () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); + + this._slashCommands.push({ + command: 'agents', + detail: localize('slashCommand.agents', "View and manage custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Agents), + }); + this._slashCommands.push({ + command: 'skills', + detail: localize('slashCommand.skills', "View and manage skills"), + sortText: 'z3_skills', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Skills), + }); + this._slashCommands.push({ + command: 'instructions', + detail: localize('slashCommand.instructions', "View and manage instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Instructions), + }); + this._slashCommands.push({ + command: 'prompts', + detail: localize('slashCommand.prompts', "View and manage prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Prompts), + }); + this._slashCommands.push({ + command: 'hooks', + detail: localize('slashCommand.hooks', "View and manage hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + execute: openSection(AICustomizationManagementSection.Hooks), + }); + } + + private _registerDecorations(): void { + if (!SlashCommandHandler._slashDecosRegistered) { + SlashCommandHandler._slashDecosRegistered = true; + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashDecoType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + }); + this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, {}); + } + + this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations())); + this._updateDecorations(); + } + + private _updateDecorations(): void { + const model = this._editor.getModel(); + const value = model?.getValue() ?? ''; + const match = value.match(/^\/([\w\p{L}\d_\-\.:]+)\s?/u); + + if (!match) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + const commandName = match[1]; + const slashCommand = this._slashCommands.find(c => c.command === commandName); + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!slashCommand && !promptCommand) { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + return; + } + + // Highlight the slash command text + const commandEnd = match[0].trimEnd().length; + const commandDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 }, + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, commandDeco); + + // Show the command description as a placeholder after the command + const restOfInput = value.slice(match[0].length).trim(); + const detail = slashCommand?.detail ?? promptCommand?.description; + if (!restOfInput && detail) { + const placeholderCol = match[0].length + 1; + const placeholderDeco: IDecorationOptions[] = [{ + range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, + renderOptions: { + after: { + contentText: detail, + color: this._getPlaceholderColor(), + } + } + }]; + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, placeholderDeco); + } else { + this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); + } + } + + private _getPlaceholderColor(): string | undefined { + const theme = this.themeService.getColorTheme(); + return theme.getColor(inputPlaceholderForeground)?.toString(); + } + + private _registerCompletions(): void { + const uri = this._editor.getModel()?.uri; + if (!uri) { + return; + } + + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/\w*/g); + if (!range) { + return null; + } + + // Only allow slash commands at the start of input + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + return { + suggestions: this._slashCommands.map((c, i): CompletionItem => { + const withSlash = `/${c.command}`; + return { + label: withSlash, + insertText: c.executeImmediately ? '' : `${withSlash} `, + detail: c.detail, + range, + sortText: c.sortText ?? 'a'.repeat(i + 1), + kind: CompletionItemKind.Text, + command: c.executeImmediately ? { id: SESSIONS_EXECUTE_SLASH_COMMAND_ID, title: withSlash, arguments: [this, withSlash] } : undefined, + }; + }) + }; + } + })); + + // Dynamic completions for individual prompt/skill files (filtered to match + // what the sessions customizations view shows). + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsPromptSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/[\p{L}0-9_.:-]*/gu); + if (!range) { + return null; + } + + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + const promptCommands = await this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(token); + const userInvocable = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + if (userInvocable.length === 0) { + return null; + } + + return { + suggestions: userInvocable.map((c, i): CompletionItem => { + const label = `/${c.name}`; + return { + label: { label, description: c.description }, + insertText: `${label} `, + documentation: c.description, + range, + sortText: 'b'.repeat(i + 1), + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); + } + + private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { + const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0); + if (!varWord && model.getWordUntilPosition(position).word) { + return; + } + + if (!varWord && position.column > 1) { + const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column)); + if (textBefore !== ' ') { + return; + } + } + + let insert: Range; + let replace: Range; + if (!varWord) { + insert = replace = Range.fromPositions(position); + } else { + insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column); + replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn); + } + + return { insert, replace }; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/syncIndicator.ts b/src/vs/sessions/contrib/chat/browser/syncIndicator.ts new file mode 100644 index 0000000000000..b63411982720a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/syncIndicator.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; + +const GIT_SYNC_COMMAND = 'git.sync'; + +/** + * Renders a compact "Synchronize Changes" button next to the branch picker. + * Shows ahead/behind counts (e.g. "3↓ 2↑") and is only visible when + * the selected branch matches the repository HEAD and has changes to sync. + */ +export class SyncIndicator extends Disposable { + + private _repository: IGitRepository | undefined; + private _selectedBranch: string | undefined; + private _visible = true; + private _syncing = false; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private readonly _stateDisposables = this._register(new DisposableStore()); + + private _slotElement: HTMLElement | undefined; + private _buttonElement: HTMLElement | undefined; + + constructor( + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + } + + /** + * Sets the git repository. Subscribes to its state observable to react to + * ahead/behind changes. + */ + setRepository(repository: IGitRepository | undefined): void { + this._stateDisposables.clear(); + this._repository = repository; + + if (repository) { + this._stateDisposables.add(autorun(reader => { + repository.state.read(reader); + this._update(); + })); + } else { + this._update(); + } + } + + /** + * Sets the currently selected branch name (from the branch picker). + * The sync indicator is only shown when the selected branch is the HEAD branch. + */ + setBranch(branch: string | undefined): void { + this._selectedBranch = branch; + this._update(); + } + + /** + * Renders the sync indicator button into the given container. + */ + render(container: HTMLElement): void { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-sync-indicator')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const button = dom.append(slot, dom.$('a.action-label')); + button.tabIndex = 0; + button.role = 'button'; + this._buttonElement = button; + + this._renderDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._executeSyncCommand(); + })); + + this._renderDisposables.add(dom.addDisposableListener(button, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._executeSyncCommand(); + } + })); + + this._update(); + } + + /** + * Shows or hides the sync indicator slot. + */ + setVisible(visible: boolean): void { + this._visible = visible; + this._update(); + } + + private async _executeSyncCommand(): Promise { + if (this._syncing) { + return; + } + this._syncing = true; + this._update(); + try { + await this.commandService.executeCommand(GIT_SYNC_COMMAND, this._repository?.rootUri); + } finally { + this._syncing = false; + this._update(); + } + } + + private _getAheadBehind(): { ahead: number; behind: number } | undefined { + if (!this._repository) { + return undefined; + } + + const head = this._repository.state.get().HEAD; + if (!head?.upstream) { + return undefined; + } + + // Only show sync for the HEAD branch (i.e. the selected branch must match the actual HEAD) + if (head.name !== this._selectedBranch) { + return undefined; + } + + const ahead = head.ahead ?? 0; + const behind = head.behind ?? 0; + if (ahead === 0 && behind === 0) { + return undefined; + } + + return { ahead, behind }; + } + + private _update(): void { + if (!this._slotElement || !this._buttonElement) { + return; + } + + const counts = this._getAheadBehind(); + if ((!counts && !this._syncing) || !this._visible) { + this._slotElement.style.display = 'none'; + return; + } + + this._slotElement.style.display = ''; + + dom.clearNode(this._buttonElement); + dom.append(this._buttonElement, renderIcon(this._syncing ? ThemeIcon.modify(Codicon.sync, 'spin') : Codicon.sync)); + + if (counts) { + const parts: string[] = []; + if (counts.behind > 0) { + parts.push(`${counts.behind}↓`); + } + if (counts.ahead > 0) { + parts.push(`${counts.ahead}↑`); + } + + const label = dom.append(this._buttonElement, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = parts.join('\u00a0'); + } + + this._buttonElement.title = localize( + 'syncIndicator.tooltip', + "Synchronize Changes ({0} to pull, {1} to push)", + counts?.behind ?? 0, + counts?.ahead ?? 0, + ); + } +} diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts new file mode 100644 index 0000000000000..fb3efadd52f41 --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.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 { URI } from '../../../../base/common/uri.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; + +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in prompts shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + +/** + * Prompt path for built-in prompts bundled with the Sessions app. + */ +export interface IBuiltinPromptPath { + readonly uri: URI; + readonly storage: AICustomizationPromptsStorage; + readonly type: PromptsType; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts new file mode 100644 index 0000000000000..e20913a9a98c8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/runScriptCustomTaskWidget.fixture.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { IRunScriptCustomTaskWidgetState, RunScriptCustomTaskWidget, WORKTREE_CREATED_RUN_ON } from '../../browser/runScriptCustomTaskWidget.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +const filledLabel = 'Start Dev Server'; +const filledCommand = 'npm run dev'; +const workspaceUnavailableReason = 'Workspace storage is unavailable for this session'; + +function renderWidget(ctx: ComponentFixtureContext, state: IRunScriptCustomTaskWidgetState): void { + ctx.container.style.width = '600px'; + ctx.container.style.padding = '0'; + ctx.container.style.borderRadius = 'var(--vscode-cornerRadius-xLarge)'; + ctx.container.style.backgroundColor = 'var(--vscode-quickInput-background)'; + ctx.container.style.overflow = 'hidden'; + + const widget = ctx.disposableStore.add(new RunScriptCustomTaskWidget(state)); + ctx.container.appendChild(widget.domNode); +} + +function defineFixture(state: IRunScriptCustomTaskWidgetState) { + return defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderWidget(ctx, state), + }); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + WorkspaceSelectedEmpty: defineFixture({ + target: 'workspace', + }), + + WorkspaceSelectedCheckedEmpty: defineFixture({ + target: 'workspace', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceSelectedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + }), + + WorkspaceSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'workspace', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedEmpty: defineFixture({ + target: 'user', + }), + + UserSelectedCheckedEmpty: defineFixture({ + target: 'user', + runOn: WORKTREE_CREATED_RUN_ON, + }), + + UserSelectedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + }), + + UserSelectedCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedEmpty: defineFixture({ + target: 'user', + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + WorkspaceUnavailableFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + }), + + WorkspaceUnavailableCheckedFilled: defineFixture({ + label: filledLabel, + target: 'user', + command: filledCommand, + targetDisabledReason: workspaceUnavailableReason, + runOn: WORKTREE_CREATED_RUN_ON, + }), + + ExistingWorkspaceTaskLocked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'workspace', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + }), + + ExistingUserTaskLockedChecked: defineFixture({ + label: filledLabel, + labelDisabledReason: 'This name comes from an existing task and cannot be changed here.', + command: filledCommand, + commandDisabledReason: 'This command comes from an existing task and cannot be changed here.', + target: 'user', + targetDisabledReason: 'This existing task cannot be moved between workspace and user storage.', + runOn: WORKTREE_CREATED_RUN_ON, + }), +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 46e663337003c..073e6c42d02b3 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -15,14 +15,19 @@ import { IJSONEditingService, IJSONValue } from '../../../../../workbench/servic 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 { 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 { + resource: URI.parse('file:///session'), + isUntitled: false, + label: 'session', repository: opts.repository, worktree: opts.worktree, + worktreeBranchName: undefined, + providerType: 'background', } as IActiveSessionItem; } @@ -30,6 +35,14 @@ function makeTask(label: string, command?: string, inSessions?: boolean): ITaskE 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 }); } @@ -44,6 +57,8 @@ suite('SessionsConfigurationService', () => { let sentCommands: { command: string }[]; let committedFiles: { session: IActiveSessionItem; fileUris: URI[] }[]; let storageService: InMemoryStorageService; + let readFileCalls: URI[]; + let activeSessionObs: ReturnType>; const userSettingsUri = URI.parse('file:///user/settings.json'); const repoUri = URI.parse('file:///repo'); @@ -55,11 +70,14 @@ suite('SessionsConfigurationService', () => { createdTerminals = []; sentCommands = []; committedFiles = []; + readFileCalls = []; 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'); @@ -104,7 +122,7 @@ suite('SessionsConfigurationService', () => { instantiationService.stub(ITerminalService, terminalServiceMock); instantiationService.stub(ISessionsManagementService, new class extends mock() { - override activeSession = observableValue('activeSession', undefined); + override activeSession = activeSessionObs; override async commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]) { committedFiles.push({ session, fileUris }); } }); @@ -128,6 +146,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 +160,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.label), ['build', 'test', 'watch']); }); test('getSessionTasks returns empty array when no worktree', async () => { @@ -167,14 +187,38 @@ suite('SessionsConfigurationService', () => { assert.deepStrictEqual(obs.get().map(t => t.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 --- - test('getNonSessionTasks returns only tasks without inSessions', async () => { + test('getNonSessionTasks returns only tasks without inSessions and with supported types', async () => { const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ 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 +226,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']); }); test('getNonSessionTasks reads from repository when no worktree', async () => { @@ -197,7 +241,26 @@ suite('SessionsConfigurationService', () => { const session = makeSession({ repository: repoUri }); const nonSessionTasks = await service.getNonSessionTasks(session); - assert.deepStrictEqual(nonSessionTasks.map(t => t.label), ['lint']); + assert.deepStrictEqual(nonSessionTasks.map(t => t.task.label), ['lint']); + }); + + test('getNonSessionTasks preserves the source target for workspace and user tasks', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + const userTasksUri = URI.from({ scheme: userSettingsUri.scheme, path: '/user/tasks.json' }); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('workspaceTask', 'npm run workspace'), + ])); + fileContents.set(userTasksUri.toString(), tasksJsonContent([ + makeTask('userTask', 'npm run user'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const nonSessionTasks = await service.getNonSessionTasks(session); + + assert.deepStrictEqual(nonSessionTasks, [ + { task: { label: 'workspaceTask', type: 'shell', command: 'npm run workspace' }, target: 'workspace' }, + { task: { label: 'userTask', type: 'shell', command: 'npm run user' }, target: 'user' }, + ] satisfies INonSessionTaskEntry[]); }); // --- addTaskToSessions --- @@ -247,6 +310,36 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); + test('addTaskToSessions updates runOptions when provided', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + makeTask('build', 'npm run build'), + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'worktreeCreated' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: { runOn: 'worktreeCreated' } }, + ]); + }); + + test('addTaskToSessions clears runOptions when default is requested', async () => { + const worktreeTasksUri = URI.parse('file:///worktree/.vscode/tasks.json'); + fileContents.set(worktreeTasksUri.toString(), tasksJsonContent([ + { ...makeTask('build', 'npm run build'), runOptions: { runOn: 'worktreeCreated' } }, + ])); + + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + await service.addTaskToSessions(makeTask('build', 'npm run build'), session, 'workspace', { runOn: 'default' }); + + assert.deepStrictEqual(jsonEdits[0].values, [ + { path: ['tasks', 0, 'inSessions'], value: true }, + { path: ['tasks', 0, 'runOptions'], value: undefined }, + ]); + }); + // --- createAndAddTask --- test('createAndAddTask writes new task with inSessions: true', async () => { @@ -256,7 +349,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); const edit = jsonEdits[0]; @@ -278,7 +371,7 @@ suite('SessionsConfigurationService', () => { ])); const session = makeSession({ repository: repoUri }); - await service.createAndAddTask('npm run dev', session, 'workspace'); + await service.createAndAddTask(undefined, 'npm run dev', session, 'workspace'); assert.strictEqual(jsonEdits.length, 1); assert.strictEqual(jsonEdits[0].uri.toString(), repoTasksUri.toString()); @@ -291,6 +384,35 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(committedFiles.length, 0, 'should not commit when there is no worktree'); }); + test('createAndAddTask writes worktreeCreated run option when requested', 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(undefined, 'npm run dev', session, 'workspace', { runOn: 'worktreeCreated' }); + + 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('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(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'); + }); + // --- runTask --- test('runTask creates terminal and sends command', async () => { @@ -305,6 +427,28 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(sentCommands[0].command, 'npm run build'); }); + test('runTask resolves npm task to npm run